接入画布

This commit is contained in:
X1627315083
2025-06-09 10:25:54 +08:00
parent 87a08f5f8f
commit c266967f16
157 changed files with 43833 additions and 1571 deletions

View File

@@ -0,0 +1,685 @@
<template>
<transition name="fade">
<div v-if="isVisible" class="brush-control-panel">
<!-- 笔刷大小控制 -->
<VerticalSlider
v-model="brushSize"
:min="1"
:max="100"
:presets="sizePresets"
:memorized-values="memorizedSizes"
:active-threshold="1"
custom-class="size-slider"
:step="1"
v-model:showTooltip="showSizeTooltip"
@slide-start="handleSizeSlideStart"
@slide-end="handleSizeSlideEnd"
@click="showSizeTooltip = true"
>
<template #tooltip-content>
<div class="tooltip-header">
<div class="tooltip-title">Size</div>
<div class="tooltip-close-btn" @click.stop="closeSizeTooltip">
<SvgIcon name="CClose" size="20" />
</div>
</div>
<div class="brush-preview-container">
<div
class="brush-size-preview"
:style="{
width: `${Math.min(100, brushSize)}px`,
height: `${Math.min(100, brushSize)}px`,
backgroundColor: showColorPicker ? brushColor : '#888888',
}"
></div>
</div>
<div class="tooltip-content">
<div class="tooltip-text">{{ Math.round(brushSize) }}px</div>
<div class="tooltip-controls">
<button
v-if="!memorizedSizes.includes(brushSize)"
class="control-btn add"
@click="memorizeSize"
>
+
</button>
<button
class="control-btn remove"
@click="removeMemorizedSize"
v-if="canRemoveSize"
>
-
</button>
</div>
</div>
</template>
</VerticalSlider>
<!-- 颜色选择器 - 仅在特定工具下显示 -->
<div v-if="showColorPicker" class="color-picker-container">
<label for="color-picker" class="current-color-label">
<div
class="current-color"
:style="{ backgroundColor: brushColor }"
></div>
</label>
<input
type="color"
id="color-picker"
class="system-color-picker"
v-model="customColor"
@input="setBrushColor(customColor)"
/>
</div>
<!-- 透明度控制 - 仅在特定工具下显示 -->
<VerticalSlider
v-if="showOpacitySlider"
v-model="brushOpacity"
:min="0"
:max="1"
:presets="opacityPresets"
:memorized-values="memorizedOpacities"
:is-percentage="true"
custom-class="opacity-slider"
:active-threshold="0.01"
:step="0.01"
v-model:showTooltip="showOpacityTooltip"
@slide-start="handleOpacitySlideStart"
@slide-end="handleOpacitySlideEnd"
@click="showOpacityTooltip = true"
>
<template #tooltip-content>
<div class="tooltip-header">
<div class="tooltip-title">Opacity</div>
<div class="tooltip-close-btn" @click.stop="closeOpacityTooltip">
<SvgIcon name="CClose" size="20" />
</div>
</div>
<div class="opacity-preview">
<div class="opacity-checker"></div>
<div
class="opacity-color"
:style="{
backgroundColor: brushColor,
opacity: brushOpacity,
}"
></div>
</div>
<div class="tooltip-content">
<div class="tooltip-text">
{{ Math.round(brushOpacity * 100) }}%
</div>
<div class="tooltip-controls">
<button
class="control-btn add"
v-if="!memorizedOpacities.includes(brushOpacity)"
@click="memorizeOpacity"
>
+
</button>
<button
class="control-btn remove"
@click="removeMemorizedOpacity"
v-if="canRemoveOpacity"
>
-
</button>
</div>
</div>
</template>
</VerticalSlider>
</div>
</transition>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
import { BrushStore } from "../store/BrushStore";
import { OperationType } from "../utils/layerHelper";
import { inject } from "vue";
import VerticalSlider from "./VerticalSlider.vue";
const props = defineProps({
activeTool: {
type: String,
required: true,
},
});
// 工具管理器和画布管理器
const toolManager = inject("toolManager");
const canvasManager = inject("canvasManager");
// 可见性控制
const isVisible = computed(() => {
return [
OperationType.DRAW,
OperationType.ERASER,
OperationType.RED_BRUSH,
OperationType.GREEN_BRUSH,
].includes(props.activeTool);
});
// 控制颜色选择器的显示
const showColorPicker = computed(() => {
return props.activeTool === OperationType.DRAW;
});
// 控制透明度滑块的显示
const showOpacitySlider = computed(() => {
return props.activeTool === OperationType.DRAW;
});
// 笔刷大小相关
const brushSize = ref(BrushStore.state.size);
const showSizeTooltip = ref(false);
const sizePresets = ref([5, 10, 20, 50]); // 预设大小,便于吸附
const memorizedSizes = ref([]);
const canRemoveSize = computed(() => {
return memorizedSizes.value.includes(brushSize.value);
});
const isSizeSliding = ref(false); // 是否正在滑动大小滑块
// 笔刷颜色相关
const brushColor = ref(BrushStore.state.color);
const customColor = ref(BrushStore.state.color);
// 笔刷透明度相关
const brushOpacity = ref(BrushStore.state.opacity);
const showOpacityTooltip = ref(false);
const opacityPresets = ref([0.1, 0.2, 0.5, 0.8]); // 预设透明度,便于吸附
const memorizedOpacities = ref([]);
const canRemoveOpacity = computed(() => {
return memorizedOpacities.value.includes(brushOpacity.value);
});
const isOpacitySliding = ref(false); // 是否正在滑动透明度滑块
// 添加计时器变量用于控制外部变化引起的提示框自动隐藏
const sizeTooltipTimer = ref(null);
const opacityTooltipTimer = ref(null);
const TOOLTIP_HIDE_DELAY = 1500; // 与VerticalSlider组件保持一致的延迟时间
// 处理滑块开始和结束事件
function handleSizeSlideStart() {
isSizeSliding.value = true;
}
function handleSizeSlideEnd(event) {
isSizeSliding.value = false;
}
function handleOpacitySlideStart() {
isOpacitySliding.value = true;
}
function handleOpacitySlideEnd(event) {
isOpacitySliding.value = false;
}
// 设置笔刷大小
function setBrushSize(size) {
brushSize.value = size;
BrushStore.setBrushSize(size);
// 如果工具管理器存在,立即应用此更改
if (toolManager) {
toolManager.updateBrushSize(size);
}
}
// 设置笔刷颜色
function setBrushColor(color) {
brushColor.value = color;
customColor.value = color;
BrushStore.setBrushColor(color);
// 如果工具管理器存在,立即应用此更改
if (toolManager && props.activeTool === OperationType.DRAW) {
toolManager.updateBrushColor(color);
}
}
// 设置笔刷透明度
function setBrushOpacity(opacity) {
brushOpacity.value = opacity;
BrushStore.setBrushOpacity(opacity);
// 如果工具管理器存在,立即应用此更改
if (toolManager) {
toolManager.updateBrushOpacity(opacity);
}
}
// 添加用于自动隐藏大小提示框的方法
function startSizeTooltipHideTimer() {
// 清除已有的计时器
clearSizeTooltipTimer();
// 创建新计时器
sizeTooltipTimer.value = setTimeout(() => {
showSizeTooltip.value = false;
sizeTooltipTimer.value = null;
}, TOOLTIP_HIDE_DELAY);
}
// 清除大小提示框隐藏计时器
function clearSizeTooltipTimer() {
if (sizeTooltipTimer.value) {
clearTimeout(sizeTooltipTimer.value);
sizeTooltipTimer.value = null;
}
}
// 添加用于自动隐藏透明度提示框的方法
function startOpacityTooltipHideTimer() {
// 清除已有的计时器
clearOpacityTooltipTimer();
// 创建新计时器
opacityTooltipTimer.value = setTimeout(() => {
showOpacityTooltip.value = false;
opacityTooltipTimer.value = null;
}, TOOLTIP_HIDE_DELAY);
}
// 清除透明度提示框隐藏计时器
function clearOpacityTooltipTimer() {
if (opacityTooltipTimer.value) {
clearTimeout(opacityTooltipTimer.value);
opacityTooltipTimer.value = null;
}
}
// 主动关闭提示框
function closeSizeTooltip() {
showSizeTooltip.value = false;
clearSizeTooltipTimer(); // 清除任何存在的计时器
}
function closeOpacityTooltip() {
showOpacityTooltip.value = false;
clearOpacityTooltipTimer(); // 清除任何存在的计时器
}
// 记忆当前笔刷大小
function memorizeSize() {
if (!memorizedSizes.value.includes(brushSize.value)) {
memorizedSizes.value.push(brushSize.value);
// 记忆值限制为最多5个
if (memorizedSizes.value.length > 5) {
memorizedSizes.value.shift();
}
}
}
// 删除当前记忆的笔刷大小
function removeMemorizedSize() {
if (memorizedSizes.value.includes(brushSize.value)) {
const index = memorizedSizes.value.indexOf(brushSize.value);
memorizedSizes.value.splice(index, 1);
}
}
// 记忆当前笔刷透明度
function memorizeOpacity() {
if (!memorizedOpacities.value.includes(brushOpacity.value)) {
memorizedOpacities.value.push(brushOpacity.value);
// 记忆值限制为最多5个
if (memorizedOpacities.value.length > 5) {
memorizedOpacities.value.shift();
}
}
}
// 删除当前记忆的笔刷透明度
function removeMemorizedOpacity() {
if (memorizedOpacities.value.includes(brushOpacity.value)) {
const index = memorizedOpacities.value.indexOf(brushOpacity.value);
memorizedOpacities.value.splice(index, 1);
}
}
// 监听工具的变化
watch(
() => props.activeTool,
(newTool) => {
// 当切换到橡皮擦工具时,可以设置特殊的默认值
if (newTool === OperationType.ERASER) {
// 橡皮擦模式下不需要调整颜色,但可能会调整大小和不透明度
} else if (newTool === OperationType.DRAW) {
// 恢复到绘制模式时可能有特殊设置
}
}
);
// 监听brushSize的变化更新到BrushStore
watch(
() => brushSize.value,
(newSize) => {
setBrushSize(newSize);
}
);
// 监听brushOpacity的变化更新到BrushStore
watch(
() => brushOpacity.value,
(newOpacity) => {
setBrushOpacity(newOpacity);
}
);
// 监听BrushStore中的变化
watch(
() => BrushStore.state.size,
(newSize) => {
if (Math.abs(brushSize.value - newSize) > 0.1) {
brushSize.value = newSize;
// 当外部修改了笔刷大小时,显示提示框
showSizeTooltip.value = true;
// 启动自动隐藏计时器
startSizeTooltipHideTimer();
}
}
);
watch(
() => BrushStore.state.opacity,
(newOpacity) => {
if (Math.abs(brushOpacity.value - newOpacity) > 0.01) {
brushOpacity.value = newOpacity;
// 当外部修改了笔刷透明度时,显示提示框
showOpacityTooltip.value = true;
// 启动自动隐藏计时器
startOpacityTooltipHideTimer();
}
}
);
watch(
() => BrushStore.state.color,
(newColor) => {
if (brushColor.value !== newColor) {
brushColor.value = newColor;
customColor.value = newColor;
}
}
);
onMounted(() => {
// 初始化时从BrushStore获取当前值
brushSize.value = BrushStore.state.size;
brushOpacity.value = BrushStore.state.opacity;
brushColor.value = BrushStore.state.color;
customColor.value = BrushStore.state.color;
});
onBeforeUnmount(() => {
// 组件卸载前清除所有计时器
clearSizeTooltipTimer();
clearOpacityTooltipTimer();
});
// 监听showSizeTooltip和showOpacityTooltip的变化防止两个滑块的提示框同时显示
watch(
() => showSizeTooltip.value,
(newValue) => {
if (newValue && showOpacityTooltip.value) {
// 如果大小提示框显示,则隐藏透明度提示框
showOpacityTooltip.value = false;
}
}
);
watch(
() => showOpacityTooltip.value,
(newValue) => {
if (newValue && showSizeTooltip.value) {
// 如果透明度提示框显示,则隐藏大小提示框
showSizeTooltip.value = false;
}
}
);
</script>
<style scoped lang="less">
.brush-control-panel {
position: absolute;
top: 50%;
left: 15px;
transform: translateY(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
background: rgba(255, 255, 255, 0.8);
border-radius: 5px;
padding: 15px 3px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05);
z-index: 8;
backdrop-filter: blur(2px);
color: #333;
user-select: none;
-webkit-user-select: none;
}
// 笔刷大小预览相关样式
.brush-preview-container {
width: 110px;
height: 110px;
display: flex;
justify-content: center;
align-items: center;
background: repeating-conic-gradient(#f5f5f5 0% 25%, #ffffff 0% 50%) 50% /
10px 10px;
border-radius: 6px;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 5px;
}
.brush-size-preview {
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
// 透明度预览相关样式
.opacity-preview {
width: 110px;
height: 110px;
border-radius: 6px;
overflow: hidden;
position: relative;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 5px;
}
.opacity-checker {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: repeating-conic-gradient(#f5f5f5 0% 25%, #ffffff 0% 50%) 50% /
10px 10px;
}
.opacity-color {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
// 工具提示内容样式
.tooltip-content {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.tooltip-text {
font-size: 14px;
font-weight: 500;
color: #333;
background: #f5f5f5;
padding: 2px 8px;
border-radius: 4px;
min-width: 50px;
text-align: center;
}
.tooltip-controls {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 3px;
}
.control-btn {
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: #f0f0f0;
color: #333;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
&:hover {
background: #e0e0e0;
}
&:active {
transform: scale(0.95);
}
&.add {
color: #4caf50;
&:hover {
background: rgba(76, 175, 80, 0.1);
}
}
&.remove {
color: #f44336;
&:hover {
background: rgba(244, 67, 54, 0.1);
}
}
}
// 颜色选择器样式
.color-picker-container {
position: relative;
width: 100%;
display: flex;
justify-content: center;
}
.current-color-label {
cursor: pointer;
}
.current-color {
width: 32px;
height: 32px;
border-radius: 8px;
border: 2px solid #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
cursor: pointer;
&:hover {
transform: scale(1.05);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.1);
}
}
.system-color-picker {
width: 0;
height: 0;
opacity: 0;
position: absolute;
pointer-events: none;
}
// 工具提示标题和关闭按钮
.tooltip-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.tooltip-title {
font-size: 16px;
color: #333;
font-weight: 600;
}
.tooltip-close-btn {
position: absolute;
right: 3px;
top: 3px;
width: 20px;
height: 20px;
border: none;
background: transparent;
color: #999;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
padding: 0;
margin: 0;
transition: all 0.2s ease;
z-index: 99;
&:hover {
background: rgba(0, 0, 0, 0.05);
color: #333;
}
&:active {
transform: scale(0.9);
}
}
// 淡入淡出动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s, transform 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(-10px) translateY(-50%);
}
// 响应式调整
@media (max-height: 600px) {
.brush-control-panel {
transform: translateY(-50%);
}
}
@media (max-width: 768px) {
.brush-control-panel {
left: 10px;
// padding: 12px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,525 @@
<script setup>
import {
inject,
ref,
provide,
onMounted,
computed,
watch,
onUnmounted,
} from "vue";
import { OperationType } from "../utils/layerHelper";
import BrushPanel from "./BrushPanel.vue";
import { BrushStore } from "../store/BrushStore";
// 提供brushStore给子组件
provide("brushStore", BrushStore);
const toolManager = inject("toolManager");
const layerManager = inject("layerManager");
const props = defineProps({
activeTool: String,
canvasWidth: Number,
canvasHeight: Number,
canvasColor: String,
brushSize: Number,
});
const emit = defineEmits([
"update:canvasWidth",
"update:canvasHeight",
"update:canvasColor",
"update:brushSize",
"canvas-size-change",
"canvas-color-change",
]);
// 笔刷面板相关状态
const showBrushPanel = ref(false);
const brushPanelRef = ref(null);
// 计算属性
const shouldShowBrushSettings = computed(() => {
return props.activeTool === OperationType.DRAW;
});
function updateCanvasSize() {
if (!layerManager) {
console.warn("LayerManager 未初始化,无法调整背景层尺寸");
return;
}
// 检查画布上是否有除了背景层的其他元素
const hasOtherElements = layerManager.layers.value.some((layer) => {
if (layer.isBackground) return false;
// 检查普通图层是否有对象
if (layer.fabricObjects && layer.fabricObjects.length > 0) return true;
// 检查固定图层是否有对象
if (layer.isFixed && layer.fabricObjects && layer.fabricObjects.length > 0)
return true;
return false;
});
if (hasOtherElements) {
// 有其他元素时使用等比缩放命令
layerManager.resizeCanvasWithScale(props.canvasWidth, props.canvasHeight);
} else {
// 只有背景层时使用普通调整命令
layerManager.resizeCanvas(props.canvasWidth, props.canvasHeight);
}
emit("canvas-size-change");
}
function updateCanvasColor() {
if (!layerManager) {
console.warn("LayerManager 未初始化,无法更改背景色");
return;
}
// 更新背景层颜色而不是画布颜色
layerManager.updateBackgroundColor(props.canvasColor);
emit("canvas-color-change");
}
// 切换笔刷面板显示状态
function toggleBrushPanel() {
showBrushPanel.value = !showBrushPanel.value;
}
// 处理笔刷大小变化
function handleBrushSizeChange(event) {
const newSize = parseFloat(event.target.value);
emit("update:brushSize", newSize);
}
// 处理笔刷设置变化将BrushStore的数据同步到brushManager
function syncBrushStoreToManager() {
if (!toolManager?.brushManager) return;
const brushManager = toolManager.brushManager;
// 检查画笔是否正在更新中
if (brushManager.isUpdatingBrush) {
console.warn("画笔正在更新中,请稍候...");
// 延迟重试,确保在画笔更新完成后应用最新设置
setTimeout(syncBrushStoreToManager, 100);
return;
}
// 监听BrushStore的变化更新brushManager
const size = BrushStore.state.size;
const color = BrushStore.state.color;
const type = BrushStore.state.type;
const opacity = BrushStore.state.opacity;
const textureEnabled = BrushStore.state.textureEnabled;
const texturePath = BrushStore.state.texturePath;
const textureScale = BrushStore.state.textureScale;
// 将所有更改一次性应用减少updateBrush调用次数
let needsUpdate = false;
if (
brushManager.brushSize &&
typeof brushManager.setBrushSize === "function" &&
brushManager.getBrushSize() !== size
) {
brushManager.brushSize.value = size; // 直接设置值避免触发updateBrush
needsUpdate = true;
}
if (
brushManager.brushColor &&
typeof brushManager.setBrushColor === "function" &&
brushManager.getBrushColor() !== color
) {
brushManager.brushColor.value = color; // 直接设置值避免触发updateBrush
needsUpdate = true;
}
if (
typeof brushManager.setBrushType === "function" &&
brushManager.getCurrentBrushType() !== type
) {
brushManager.setBrushType(type);
needsUpdate = true;
}
if (typeof brushManager.setBrushOpacity === "function") {
brushManager.setBrushOpacity(opacity);
needsUpdate = true;
}
// 同步材质相关设置
if (textureEnabled && texturePath) {
if (typeof brushManager.setTexturePath === "function") {
brushManager.setTexturePath(texturePath);
needsUpdate = true;
}
if (
typeof brushManager.setTextureScale === "function" &&
brushManager.getTextureScale() !== textureScale
) {
brushManager.textureScale.value = textureScale; // 直接设置值避免触发updateBrush
needsUpdate = true;
}
}
// 只在有变化时调用一次updateBrush减少重绘次数
if (needsUpdate && typeof brushManager.updateBrush === "function") {
brushManager.updateBrush();
}
}
// 点击外部时关闭笔刷面板
function handleClickOutside(event) {
if (
showBrushPanel.value &&
brushPanelRef.value &&
!brushPanelRef.value.contains(event.target) &&
!event.target.closest(".brush-selector")
) {
showBrushPanel.value = false;
}
}
onMounted(() => {
// 获取工具管理器和笔刷管理器
const brushManager = toolManager?.brushManager;
// 设置初始的可用笔刷类型
if (brushManager) {
const availableBrushes = brushManager.getBrushTypes();
BrushStore.setAvailableBrushes(availableBrushes);
// 初始化BrushStore与brushManager的数据同步
BrushStore.setBrushSize(brushManager.brushSize?.value || 5);
BrushStore.setBrushColor(brushManager.brushColor?.value || "#000000");
BrushStore.setBrushType(brushManager.getCurrentBrushType() || "pencil");
}
// 添加点击外部关闭面板的事件监听
document.addEventListener("mousedown", handleClickOutside);
// 监听BrushStore的变化同步到brushManager
const unwatch = watch(
() => [
BrushStore.state.size,
BrushStore.state.color,
BrushStore.state.type,
BrushStore.state.opacity,
BrushStore.state.textureEnabled,
BrushStore.state.texturePath,
BrushStore.state.textureScale,
],
syncBrushStoreToManager,
{ deep: true }
);
// 组件卸载时移除事件监听
onUnmounted(() => {
document.removeEventListener("mousedown", handleClickOutside);
unwatch();
});
});
</script>
<template>
<div class="canvas-header">
<span class="canvas-title">Canvas</span>
<!-- 默认设置 -->
<div
v-if="
!activeTool ||
activeTool === OperationType.SELECT ||
activeTool === OperationType.PAN
"
class="canvas-settings"
>
<div class="setting-group">
<span class="setting-label">Width</span>
<input
type="text"
:value="canvasWidth"
class="setting-input"
@input="$emit('update:canvasWidth', Number($event.target.value))"
@change="updateCanvasSize"
/>
</div>
<div class="setting-group">
<span class="setting-label">Height</span>
<input
type="text"
:value="canvasHeight"
class="setting-input"
@input="$emit('update:canvasHeight', Number($event.target.value))"
@change="updateCanvasSize"
/>
</div>
<div class="setting-group">
<span class="setting-label">Color</span>
<div class="color-picker-wrapper">
<input
type="color"
:value="canvasColor"
class="color-picker"
@input="$emit('update:canvasColor', $event.target.value)"
@change="updateCanvasColor"
/>
<span class="color-dropdown">▼</span>
</div>
</div>
</div>
<!-- 绘图工具设置 -->
<div v-if="shouldShowBrushSettings" class="canvas-settings">
<!-- 简化的笔刷控制UI -->
<!-- <div class="setting-group">
<span class="setting-label">大小:</span>
<input
type="range"
:value="BrushStore.state.size"
min="0.5"
max="100"
step="0.5"
class="size-slider"
@input="handleBrushSizeChange"
/>
<span class="size-value">{{ BrushStore.state.size }}px</span>
</div> -->
<div class="setting-group">
<span class="setting-label">笔刷:</span>
<div class="brush-selector" @click="toggleBrushPanel">
<div
class="brush-preview"
:style="{
backgroundColor: BrushStore.state.color,
height: BrushStore.state.type === 'marker' ? '4px' : '2px',
opacity: BrushStore.state.opacity,
}"
></div>
<span class="brush-dropdown">▼</span>
</div>
<!-- 笔刷面板 -->
<div
v-if="showBrushPanel"
class="brush-panel-container"
ref="brushPanelRef"
>
<BrushPanel />
</div>
</div>
<div class="setting-group">
<span class="setting-label">颜色:</span>
<div class="color-picker-wrapper">
<input
type="color"
:value="BrushStore.state.color"
class="color-picker"
@input="BrushStore.setBrushColor($event.target.value)"
/>
<span class="color-dropdown">▼</span>
</div>
</div>
</div>
<!-- 文本工具设置 -->
<div v-if="activeTool === OperationType.TEXT" class="canvas-settings">
<div class="setting-group">
<span class="setting-label">Font:</span>
<select class="font-select">
<option value="Arial">Arial</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option>
</select>
</div>
<div class="setting-group">
<span class="setting-label">Size:</span>
<input
type="number"
class="setting-input"
value="16"
min="8"
max="72"
/>
</div>
<div class="setting-group">
<span class="setting-label">Color:</span>
<div class="color-picker-wrapper">
<input type="color" class="color-picker" value="#000000" />
<span class="color-dropdown">▼</span>
</div>
</div>
</div>
<!-- 上传工具设置 -->
<div v-if="activeTool === OperationType.UPLOAD" class="canvas-settings">
<div class="setting-group">
<span class="setting-label">Upload Type:</span>
<select class="setting-select">
<option value="image">Image</option>
<option value="vector">Vector Graphics</option>
</select>
</div>
</div>
<!-- 导出设置 -->
<div class="setting-group export-group">
<span class="export-model-select">exportModel.select:</span>
<span class="export-model-dropdown"></span>
</div>
</div>
</template>
<style scoped>
.canvas-header {
display: flex;
align-items: center;
padding: 10px 20px;
border-bottom: 1px solid #e0e0e0;
user-select: none;
}
.canvas-title {
font-size: 16px;
font-weight: 500;
margin-right: 30px;
display: flex;
align-items: center;
}
.canvas-title::before {
content: "⟳";
margin-right: 5px;
font-size: 14px;
}
.canvas-settings {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
.setting-group {
display: flex;
align-items: center;
gap: 5px;
position: relative;
}
.setting-label {
font-size: 14px;
color: #333;
}
.setting-input {
width: 60px;
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.setting-select {
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.font-select {
width: 150px;
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.color-picker-wrapper {
position: relative;
display: flex;
align-items: center;
}
.color-picker {
width: 30px;
height: 30px;
border: none;
padding: 0;
background: none;
cursor: pointer;
}
.color-dropdown {
font-size: 10px;
margin-left: 5px;
color: #666;
}
.size-slider {
width: 100px;
cursor: pointer;
}
.size-value {
font-size: 12px;
color: #666;
min-width: 30px;
}
.brush-selector {
display: flex;
align-items: center;
border: 1px solid #ddd;
border-radius: 4px;
padding: 5px;
cursor: pointer;
background-color: white;
width: 80px;
justify-content: space-between;
}
.brush-preview {
width: 20px;
height: 2px;
background-color: #000;
border-radius: 1px;
}
.brush-dropdown,
.export-model-dropdown {
font-size: 10px;
margin-left: 5px;
color: #666;
}
.export-model-select {
font-size: 14px;
color: #333;
cursor: pointer;
}
.export-group {
margin-left: auto;
}
/* 笔刷面板 */
.brush-panel-container {
position: absolute;
top: calc(100% + 5px);
left: 0;
z-index: 1000;
width: 600px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
border-radius: 4px;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,506 @@
<script setup>
import { ref, inject, onMounted } from "vue";
import { Skeleton } from "ant-design-vue";
const loading = ref(true);
const shortcuts = ref([]);
const keyboardManager = inject("keyboardManager", null);
const platform = ref({});
// 初始化键盘快捷键信息
onMounted(() => {
// 添加延迟以显示骨架屏效果
setTimeout(() => {
if (keyboardManager) {
// 使用KeyboardManager的平台检测
platform.value = {
isMac: keyboardManager.platform === "mac",
isIOS: keyboardManager.platform === "ios",
isIPad:
keyboardManager.platform === "ios" &&
/iPad/.test(window.navigator.userAgent),
isTouchDevice: keyboardManager.isTouchDevice,
isWindows: keyboardManager.platform === "windows",
isAndroid: keyboardManager.platform === "android",
};
// 使用KeyboardManager的API获取所有快捷键
const managerShortcuts = keyboardManager.getShortcuts();
// 转换为组件所需的格式
shortcuts.value = convertShortcuts(managerShortcuts);
} else {
// 如果没有注入keyboardManager使用默认检测和默认快捷键
platform.value = detectPlatform();
shortcuts.value = generateDefaultShortcuts();
}
loading.value = false;
}, 500);
});
// 转换KeyboardManager返回的快捷键格式为组件需要的格式
function convertShortcuts(managerShortcuts) {
// 转换快捷键列表
const result = [];
// 基本的Action到显示名称的映射
const actionDisplayMap = {
undo: "撤销",
redo: "重做",
delete: "删除选中元素",
selectAll: "全选",
copy: "复制",
paste: "粘贴",
cut: "剪切",
save: "保存",
selectTool: "选择工具",
increaseBrushSize: "增加笔触大小",
decreaseBrushSize: "减小笔触大小",
toggleTempTool: "临时切换工具",
newLayer: "新建图层",
groupLayers: "组合图层",
ungroupLayers: "取消组合",
mergeLayers: "合并图层",
};
// 工具ID到显示名称的映射
const toolDisplayMap = {
select: "选择模式",
draw: "绘画模式",
eraser: "橡皮擦模式",
eyedropper: "吸色工具",
pan: "移动画布",
lasso: "套索工具",
area_custom: "自由选区工具",
wave: "波浪工具",
liquify: "液化工具",
};
// 处理每个快捷键
for (const shortcut of managerShortcuts) {
let actionDisplay = actionDisplayMap[shortcut.action] || shortcut.action;
// 特殊处理工具选择
if (
shortcut.action === "selectTool" &&
shortcut.param &&
toolDisplayMap[shortcut.param]
) {
actionDisplay = toolDisplayMap[shortcut.param];
}
result.push({
action: actionDisplay,
windows: shortcut.key.replace(/cmdOrCtrl\+/g, "Ctrl+"),
mac: shortcut.key.replace(/cmdOrCtrl\+/g, "⌘+"),
touch: shortcut.touch || "触控界面点击对应工具",
displayKey: shortcut.displayKey,
});
}
// 添加一些组件特定的快捷键
result.push({
action: "缩放画布",
windows: "鼠标滚轮",
mac: "鼠标滚轮 或 触控板缩放手势",
touch: "双指捏合",
});
return result;
}
// 检测平台 - 作为备用
function detectPlatform() {
const userAgent = window.navigator.userAgent;
return {
isMac: /Mac/.test(userAgent),
isIOS: /iPad|iPhone|iPod/.test(userAgent) && !window.MSStream,
isIPad: /iPad/.test(userAgent),
isTouchDevice: "ontouchstart" in window || navigator.maxTouchPoints > 0,
isWindows: /Win/.test(userAgent),
isAndroid: /Android/.test(userAgent),
};
}
// 生成默认快捷键描述
function generateDefaultShortcuts() {
return [
{
action: "撤销",
windows: "Ctrl+Z",
mac: "⌘+Z",
touch: "双指向右轻扫",
},
{
action: "重做",
windows: "Ctrl+Y 或 Ctrl+Shift+Z",
mac: "⌘+Shift+Z",
touch: "双指向左轻扫",
},
{
action: "删除选中元素",
windows: "Delete 或 Backspace",
mac: "Delete 或 ⌫",
touch: "长按选中元素后点击删除",
},
{
action: "全选",
windows: "Ctrl+A",
mac: "⌘+A",
touch: "无",
},
{
action: "复制",
windows: "Ctrl+C",
mac: "⌘+C",
touch: "无",
},
{
action: "粘贴",
windows: "Ctrl+V",
mac: "⌘+V",
touch: "无",
},
{
action: "剪切",
windows: "Ctrl+X",
mac: "⌘+X",
touch: "无",
},
{
action: "缩放画布",
windows: "鼠标滚轮",
mac: "鼠标滚轮 或 触控板缩放手势",
touch: "双指捏合",
},
{
action: "移动画布",
windows: "Alt+拖动 或 鼠标中键拖动",
mac: "Option+拖动 或 触控板双指拖动",
touch: "双指拖动",
},
{
action: "绘画模式",
windows: "B",
mac: "B",
touch: "点击画笔工具",
},
{
action: "选择模式",
windows: "M",
mac: "M",
touch: "点击选择工具",
},
{
action: "橡皮擦模式",
windows: "E",
mac: "E",
touch: "点击橡皮擦工具",
},
{
action: "吸色工具",
windows: "I",
mac: "I",
touch: "点击吸色工具",
},
{
action: "增加笔触大小",
windows: "]",
mac: "]",
touch: "拖动笔刷大小滑块",
},
{
action: "减小笔触大小",
windows: "[",
mac: "[",
touch: "拖动笔刷大小滑块",
},
{
action: "增加材质图片大小",
windows: "Shift+]",
mac: "⇧+]",
touch: "拖动材质大小滑块",
},
{
action: "减小材质图片大小",
windows: "Shift+[",
mac: "⇧+[",
touch: "拖动材质大小滑块",
},
{
action: "上传图片",
windows: "Ctrl+O",
mac: "⌘+O",
touch: "点击上传按钮",
},
];
}
// 获取当前平台的快捷键文本
function getShortcutForCurrentPlatform(shortcut) {
if (platform.value.isTouchDevice) {
return shortcut.touch;
} else if (platform.value.isMac) {
return shortcut.displayKey || shortcut.mac;
} else {
return shortcut.displayKey || shortcut.windows;
}
}
// 按分类获取快捷键
function getShortcutsByCategory(category) {
const categoryMap = {
basic: [
"撤销",
"重做",
"全选",
"复制",
"粘贴",
"剪切",
"删除选中元素",
"上传图片",
],
view: ["缩放画布", "移动画布"],
tools: [
"绘画模式",
"选择模式",
"橡皮擦模式",
"吸色工具",
"套索工具",
"自由选区工具",
"波浪工具",
"液化工具",
],
brush: [
"增加笔触大小",
"减小笔触大小",
"增加材质图片大小",
"减小材质图片大小",
],
layer: ["新建图层", "组合图层", "取消组合", "合并图层"],
};
return shortcuts.value.filter((s) =>
categoryMap[category]?.includes(s.action)
);
}
</script>
<template>
<div class="keyboard-shortcut-help">
<h2>键盘快捷键 & 操作指南</h2>
<Skeleton active :loading="loading">
<div class="platform-info">
检测到的平台:
<span v-if="platform.isMac">MacOS</span>
<span v-else-if="platform.isWindows">Windows</span>
<span v-else-if="platform.isIPad">iPad</span>
<span v-else-if="platform.isIOS">iOS</span>
<span v-else-if="platform.isAndroid">Android</span>
<span v-else>其他</span>
<span v-if="platform.isTouchDevice"> (触控设备)</span>
</div>
<div class="shortcuts-category">
<h3>基本操作</h3>
<table class="shortcuts-table">
<thead>
<tr>
<th>操作</th>
<th>快捷键/手势</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in getShortcutsByCategory('basic')"
:key="item.action"
>
<td>{{ item.action }}</td>
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="shortcuts-category">
<h3>视图操作</h3>
<table class="shortcuts-table">
<thead>
<tr>
<th>操作</th>
<th>快捷键/手势</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in getShortcutsByCategory('view')"
:key="item.action"
>
<td>{{ item.action }}</td>
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="shortcuts-category">
<h3>工具切换</h3>
<table class="shortcuts-table">
<thead>
<tr>
<th>操作</th>
<th>快捷键/手势</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in getShortcutsByCategory('tools')"
:key="item.action"
>
<td>{{ item.action }}</td>
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="shortcuts-category">
<h3>笔刷调整</h3>
<table class="shortcuts-table">
<thead>
<tr>
<th>操作</th>
<th>快捷键/手势</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in getShortcutsByCategory('brush')"
:key="item.action"
>
<td>{{ item.action }}</td>
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="shortcuts-category">
<h3>图层操作</h3>
<table class="shortcuts-table">
<thead>
<tr>
<th>操作</th>
<th>快捷键/手势</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in getShortcutsByCategory('layer')"
:key="item.action"
>
<td>{{ item.action }}</td>
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="touch-tips" v-if="platform.isTouchDevice">
<h3>触控设备提示</h3>
<ul>
<li>长按图层面板可访问更多选项</li>
<li>双击元素可快速进入编辑模式</li>
<li>双指拖动可平移画布</li>
<li>双指捏合可缩放画布</li>
<li>双指连按可显示元素变换控制点</li>
<li>三指左右滑动可进行撤销/重做操作</li>
</ul>
</div>
</Skeleton>
</div>
</template>
<style scoped>
.keyboard-shortcut-help {
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
max-width: 600px;
margin: 0 auto;
}
h2 {
margin-top: 0;
margin-bottom: 16px;
font-size: 18px;
}
h3 {
font-size: 16px;
margin-top: 20px;
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 1px solid #eaeaea;
}
.platform-info {
margin-bottom: 16px;
padding: 8px;
background-color: #f0f9ff;
border-radius: 4px;
font-size: 14px;
}
.shortcuts-category {
margin-bottom: 20px;
}
.shortcuts-table {
width: 100%;
border-collapse: collapse;
}
.shortcuts-table th,
.shortcuts-table td {
border: 1px solid #eaeaea;
padding: 8px 10px;
text-align: left;
}
.shortcuts-table th {
background-color: #f5f5f5;
}
.touch-tips {
margin-top: 20px;
padding: 10px;
background-color: #fffbeb;
border-radius: 4px;
border-left: 3px solid #fbbf24;
}
.touch-tips ul {
margin: 10px 0 0;
padding-left: 20px;
}
.touch-tips li {
margin-bottom: 5px;
}
@media (pointer: coarse) {
.keyboard-shortcut-help {
padding: 15px;
}
.shortcuts-table th,
.shortcuts-table td {
padding: 12px 8px;
font-size: 15px;
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
<script setup>
import { ref, onMounted, watch, onBeforeUnmount } from "vue";
const props = defineProps({
minimapManager: Object,
});
const minimapContainerRef = ref(null);
let refreshTimeout = null;
// 强制重绘小地图,添加防抖处理
const forceRefresh = () => {
if (refreshTimeout) {
clearTimeout(refreshTimeout);
}
refreshTimeout = setTimeout(() => {
if (props.minimapManager) {
props.minimapManager.refresh();
}
}, 50);
};
onMounted(() => {
if (props.minimapManager && minimapContainerRef.value) {
// 使用新的mount方法挂载小地图
props.minimapManager.mount(minimapContainerRef.value);
// 初始加载后延迟刷新一次,确保内容正确加载
setTimeout(forceRefresh, 200);
}
});
watch(
() => props.minimapManager,
(newVal) => {
if (newVal && minimapContainerRef.value) {
newVal.mount(minimapContainerRef.value);
}
}
);
// 添加resize observer以适应容器大小变化
let resizeObserver = null;
onMounted(() => {
if (window.ResizeObserver) {
// 使用防抖处理resize事件避免过于频繁的刷新
resizeObserver = new ResizeObserver(() => {
forceRefresh();
});
if (minimapContainerRef.value) {
resizeObserver.observe(minimapContainerRef.value.parentElement);
}
}
});
onBeforeUnmount(() => {
if (resizeObserver && minimapContainerRef.value) {
resizeObserver.unobserve(minimapContainerRef.value.parentElement);
resizeObserver.disconnect();
}
if (refreshTimeout) {
clearTimeout(refreshTimeout);
}
});
</script>
<template>
<div class="minimap-container">
<div class="minimap-header">
<span>画布小地图</span>
<button class="minimap-refresh" @click="forceRefresh" title="刷新小地图">
</button>
</div>
<div class="minimap-content" ref="minimapContainerRef">
<!-- 不再需要直接提供canvas引用由MinimapManager内部创建 -->
</div>
</div>
</template>
<style scoped>
.minimap-container {
position: absolute;
bottom: 10px;
left: 10px;
width: 200px;
height: 140px;
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
overflow: hidden;
z-index: 10;
display: flex;
flex-direction: column;
}
.minimap-header {
padding: 5px 8px;
font-size: 12px;
background-color: #f0f0f0;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.minimap-content {
flex: 1;
overflow: hidden;
position: relative;
}
.minimap-refresh {
cursor: pointer;
background: none;
border: none;
font-size: 14px;
padding: 0 4px;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.minimap-refresh:hover {
color: #000;
}
/* 触控设备优化 */
@media (pointer: coarse) {
.minimap-container {
width: 220px;
height: 160px;
}
.minimap-header {
padding: 8px 10px;
font-size: 14px;
}
.minimap-refresh {
font-size: 18px;
padding: 0 6px;
}
}
</style>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,411 @@
<script setup>
import { ref, inject, computed, onMounted, onUnmounted } from "vue";
import { OperationType } from "../utils/layerHelper";
import SvgIcon from "@/component/Canvas/SvgIcon/index.vue";
const props = defineProps({
activeTool: String,
minimapEnabled: {
type: Boolean,
default: true,
},
isRedGreenMode: {
type: Boolean,
default: false,
},
});
const commandManager = inject("commandManager");
// 撤销/重做按钮状态
const canUndo = ref(false);
const canRedo = ref(false);
// 监听命令管理器状态变化
commandManager.setChangeCallback((info) => {
canUndo.value = info.canUndo;
canRedo.value = info.canRedo;
});
// 撤销/重做操作
const undoFun = () => commandManager.undo();
const redoFun = () => commandManager.redo();
const emit = defineEmits([
"tool-selected",
"trigger-image-upload",
"add-text",
"undo",
"redo",
"toggle-minimap",
"zoom-in",
"zoom-out",
"toggle-red-green-mode",
]);
// 普通模式工具列表
const normalToolsList = ref([
{
id: "undo",
title: "Undo",
action: undo,
icon: { name: "CUndo", size: "20" },
class: "undo-btn",
},
{
id: "redo",
title: "Redo",
action: redo,
icon: { name: "CRedo", size: "20" },
class: "redo-btn",
},
{
id: OperationType.DRAW,
title: "Drawing",
action: () => selectTool(OperationType.DRAW),
icon: { name: "CBrush", size: "24" },
class: "draw-btn",
},
{
id: OperationType.ERASER,
title: "Eraser",
action: () => selectTool(OperationType.ERASER),
icon: { name: "CEraser", size: "22" },
class: "eraser-btn",
},
{
id: OperationType.PAN,
title: "Pan",
action: () => selectTool(OperationType.PAN),
icon: { name: "CHand", size: "28" },
class: "hand-btn",
},
{
id: OperationType.SELECT,
title: "Select",
action: () => selectTool(OperationType.SELECT),
icon: { name: "CSelect", size: "28" },
class: "select-btn",
},
{
id: OperationType.LIQUIFY,
title: "Liquefying",
action: () => selectTool(OperationType.LIQUIFY),
icon: { name: "CLiquefying", size: "32" },
class: "liquify-btn",
},
{
id: OperationType.LASSO,
title: "Lasso",
action: () => selectTool(OperationType.LASSO),
icon: { name: "CLasso", size: "28" },
class: "lasso-btn",
activeList: [
OperationType.LASSO,
OperationType.LASSO_RECTANGLE,
OperationType.AREA_CUSTOM,
OperationType.AREA_RECTANGLE,
],
},
{
id: "zoomIn",
title: "Zoom In",
action: zoomIn,
icon: { name: "CZoomIn", size: "30" },
class: "zoom-in-btn",
},
{
id: "zoomOut",
title: "Zoom Out",
action: zoomOut,
icon: { name: "CZoomOut", size: "26" },
class: "zoom-out-btn",
},
{
id: "upload",
title: "Upload Image",
action: triggerImageUpload,
icon: { name: "CUpload", size: "26" },
class: "upload-btn",
},
{
id: "addText",
title: "Add Text",
action: () => addText(),
icon: { name: "CFont", size: "20" },
class: "text-btn",
},
]);
// 红绿图模式工具列表
const redGreenToolsList = ref([
{
id: "undo",
title: "Undo",
action: undo,
icon: { name: "CUndo", size: "20" },
class: "undo-btn",
},
{
id: "redo",
title: "Redo",
action: redo,
icon: { name: "CRedo", size: "20" },
class: "redo-btn",
},
{
id: OperationType.RED_BRUSH,
title: "Red Brush (R)",
action: () => selectTool(OperationType.RED_BRUSH),
icon: { name: "CBrush", size: "24" },
class: "red-brush-btn",
style: { color: "#FF0000" },
},
{
id: OperationType.GREEN_BRUSH,
title: "Green Brush (G)",
action: () => selectTool(OperationType.GREEN_BRUSH),
icon: { name: "CBrush", size: "24" },
class: "green-brush-btn",
style: { color: "#00AA00" },
},
{
id: OperationType.ERASER,
title: "Eraser (E)",
action: () => selectTool(OperationType.ERASER),
icon: { name: "CEraser", size: "22" },
class: "eraser-btn",
},
{
id: "zoomIn",
title: "Zoom In",
action: zoomIn,
icon: { name: "CZoomIn", size: "30" },
class: "zoom-in-btn",
},
{
id: "zoomOut",
title: "Zoom Out",
action: zoomOut,
icon: { name: "CZoomOut", size: "26" },
class: "zoom-out-btn",
},
]);
// 根据模式选择工具列表
const toolsList = computed(() => {
return props.isRedGreenMode ? redGreenToolsList.value : normalToolsList.value;
});
function selectTool(tool) {
emit("tool-selected", tool);
}
function triggerImageUpload() {
emit("trigger-image-upload");
}
function addText() {
emit("add-text");
}
function undo() {
if (!canUndo.value) return;
undoFun();
emit("undo", {
canUndo: canUndo.value,
canRedo: canRedo.value,
commandManager,
});
}
function redo() {
if (!canRedo.value) return;
emit("redo", {
canUndo: canUndo.value,
canRedo: canRedo.value,
commandManager,
});
redoFun();
}
function toggleMinimap() {
emit("toggle-minimap", !props.minimapEnabled);
}
function zoomIn() {
emit("zoom-in");
}
function zoomOut() {
emit("zoom-out");
}
function toggleRedGreenMode() {
emit("toggle-red-green-mode");
}
// 键盘快捷键处理
function handleKeyDown(event) {
// 在红绿图模式下处理特定快捷键
if (props.isRedGreenMode) {
const key = event.key.toUpperCase();
// 当处于输入状态时不触发快捷键
if (
event.target.tagName === "INPUT" ||
event.target.tagName === "TEXTAREA"
) {
return;
}
switch (key) {
case "R":
selectTool(OperationType.RED_BRUSH);
event.preventDefault();
break;
case "G":
selectTool(OperationType.GREEN_BRUSH);
event.preventDefault();
break;
case "E":
selectTool(OperationType.ERASER);
event.preventDefault();
break;
}
}
}
onMounted(() => {
// 添加键盘事件监听
window.addEventListener("keydown", handleKeyDown);
});
onUnmounted(() => {
// 移除键盘事件监听
window.removeEventListener("keydown", handleKeyDown);
});
</script>
<template>
<div class="tools-sidebar">
<div
v-for="tool in toolsList"
:key="tool.id"
:class="[
'tool-btn',
tool.class,
{
active:
tool.id === activeTool ||
tool.id === activeTool.toLowerCase() ||
tool?.activeList?.includes(activeTool),
disabled:
(tool.id === 'undo' && !canUndo) ||
(tool.id === 'redo' && !canRedo),
},
]"
:style="tool.style"
@click="tool.action"
>
<SvgIcon :name="tool.icon.name" :size="tool.icon.size"></SvgIcon>
<div class="tool-tooltip">{{ tool.title }}</div>
</div>
</div>
</template>
<style scoped>
.tools-sidebar {
display: flex;
flex-direction: column;
gap: 10px;
padding: 15px 10px;
border-right: 1px solid #e0e0e0;
background-color: #ffffff;
user-select: none;
}
.tool-btn {
position: relative;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
color: #333;
transition: all 0.2s ease;
}
.tool-btn:hover {
background-color: #f0f0f0;
}
.tool-btn:hover .tool-tooltip {
display: block;
}
.tool-btn.active {
background-color: #e6f7ff;
color: #1890ff;
}
.tool-btn.disabled {
cursor: not-allowed;
color: #e0e0e0;
}
.tool-tooltip {
display: none;
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 4px 8px;
border-radius: 4px;
margin-left: 8px;
white-space: nowrap;
font-size: 12px;
z-index: 10;
}
.tool-tooltip:before {
content: "";
position: absolute;
top: 50%;
right: 100%;
margin-top: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent rgba(0, 0, 0, 0.7) transparent transparent;
}
.red-green-mode {
background-color: #fff4f4;
}
.mode-indicator {
margin-bottom: 10px;
padding: 8px;
border-radius: 4px;
background-color: #ffcccc;
color: #a33;
font-size: 14px;
text-align: center;
}
.mode-label {
font-weight: bold;
}
.mode-hint {
font-size: 12px;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,688 @@
<template>
<div class="vertical-slider-container" :class="customClass">
<div
class="slider-track"
ref="sliderTrack"
@mousedown="startSliding"
@touchstart.prevent="startTouchSliding"
@click="handleClick"
>
<div
class="slider-fill"
:style="{ height: `${displayPercentage}%` }"
></div>
<div
class="slider-thumb"
:style="{
bottom: `${displayPercentage}%`,
transform: `translateX(0) translateY(8px) scale(${thumbScale})`,
}"
></div>
<div
v-for="(preset, index) in presets"
:key="`preset-${index}`"
class="slider-notch"
:class="{ active: isActivePreset(preset) }"
:style="{ bottom: `${calculatePresetPosition(preset)}%` }"
@click.stop="setValue(preset)"
></div>
<div
v-for="(preset, index) in memorizedValues"
:key="`preset-${index}`"
class="slider-notch"
:class="{ active: isActivePreset(preset) }"
:style="{ bottom: `${calculatePresetPosition(preset)}%` }"
@click.stop="setValue(preset)"
></div>
</div>
<!-- 提示框 -->
<transition name="fade">
<div class="slider-tooltip" v-if="tooltipVisible" ref="tooltip">
<slot name="tooltip-content" :value="modelValue"></slot>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
const props = defineProps({
modelValue: {
type: Number,
required: true,
},
min: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 100,
},
presets: {
type: Array,
default: () => [],
},
memorizedValues: {
type: Array,
default: () => [],
},
snapThreshold: {
type: Number,
default: 5,
},
// 用于判断当前值与预设值是否匹配的阈值
activeThreshold: {
type: Number,
default: 0.05,
},
// 是否将预设值位置按百分比计算
isPercentage: {
type: Boolean,
default: false,
},
customClass: {
type: String,
default: "",
},
// 添加步进属性,控制数值的步长
step: {
type: Number,
default: 1,
},
// 增加外部控制tooltip显示的属性
showTooltip: {
type: Boolean,
default: false,
},
// 是否启用点击外部关闭tooltip
closeOnOutsideClick: {
type: Boolean,
default: true,
},
});
const emit = defineEmits([
"update:modelValue",
"slide-start",
"slide-end",
"click",
"update:showTooltip", // 新增emit事件用于双向绑定tooltip状态
]);
const sliderTrack = ref(null);
const tooltip = ref(null);
const isSliding = ref(false);
const internalShowTooltip = ref(false);
const slideMoved = ref(false); // 用于跟踪是否发生了滑动移动
const hideTooltipTimer = ref(null); // 用于存储隐藏tooltip的计时器
// 在script setup顶部添加新的ref
const isSnapping = ref(false);
const snapLockValue = ref(null);
const snapLockTimeout = ref(null);
const thumbScale = ref(1); // 新增:用于控制滑块的缩放效果
// 定义一个统一的延迟时间常量
const TOOLTIP_HIDE_DELAY = 1500; // 1.5秒,可根据需要调整
// 开始新的隐藏tooltip计时器
function startHideTooltipTimer() {
// 先清除已有的计时器
clearHideTooltipTimer();
// 创建新计时器
hideTooltipTimer.value = setTimeout(() => {
// 只有在用户不在滑动时才隐藏tooltip
if (!isSliding.value) {
updateTooltipVisibility(false);
}
hideTooltipTimer.value = null;
}, TOOLTIP_HIDE_DELAY);
}
// 清除隐藏tooltip的计时器
function clearHideTooltipTimer() {
if (hideTooltipTimer.value) {
clearTimeout(hideTooltipTimer.value);
hideTooltipTimer.value = null;
}
}
// 计算tooltip的可见性优先使用props中的showTooltip否则使用内部状态
const tooltipVisible = computed(() => {
return props.showTooltip !== undefined
? props.showTooltip
: internalShowTooltip.value;
});
// 更新tooltip状态的方法
function updateTooltipVisibility(visible) {
if (props.showTooltip !== undefined) {
// 如果父组件提供了showTooltip属性通过emit更新
emit("update:showTooltip", visible);
} else {
// 否则更新内部状态
internalShowTooltip.value = visible;
}
}
// 计算显示百分比
const displayPercentage = computed(() => {
const range = props.max - props.min;
return ((props.modelValue - props.min) / range) * 100;
});
// 判断是否是活动预设值
function isActivePreset(preset) {
return Math.abs(props.modelValue - preset) < props.activeThreshold;
}
// 计算预设值的位置
function calculatePresetPosition(preset) {
if (props.isPercentage) {
return preset * 100;
} else {
const range = props.max - props.min;
return ((preset - props.min) / range) * 100;
}
}
// 将值舍入到最接近的步进值
function roundToStep(value) {
if (!props.step || props.step <= 0) return value;
const numSteps = Math.round((value - props.min) / props.step);
return props.min + numSteps * props.step;
}
// 鼠标滑动处理
function startSliding(event) {
isSliding.value = true;
slideMoved.value = false; // 重置滑动状态
updateTooltipVisibility(true);
clearHideTooltipTimer(); // 清除任何现有的隐藏计时器
updateValueFromMousePosition(event);
emit("slide-start");
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", stopSliding);
}
function handleMouseMove(event) {
if (isSliding.value) {
slideMoved.value = true; // 标记已经发生了滑动
updateValueFromMousePosition(event);
}
}
function updateValueFromMousePosition(event) {
const rect = sliderTrack.value.getBoundingClientRect();
const trackHeight = rect.height;
const posY = event.clientY - rect.top;
// 计算相对位置并转换为min-max范围的值
let newValue = props.max - (posY / trackHeight) * (props.max - props.min);
// 边界检查
newValue = Math.max(props.min, Math.min(props.max, newValue));
// 应用步进
if (props.step > 0) {
newValue = roundToStep(newValue);
}
// 如果当前处于吸附锁定状态,并且移动不超过锁定阈值,则保持当前值不变
if (isSnapping.value && snapLockValue.value !== null) {
// 增加吸附力度:扩大保持吸附的阈值范围
if (
Math.abs(newValue - snapLockValue.value) <
(props.snapThreshold / trackHeight) * (props.max - props.min) * 1.2
) {
newValue = snapLockValue.value;
} else {
// 移动距离超过阈值,解除吸附锁定
clearSnapLock();
}
} else {
// 检查是否可以进入吸附状态
const snapPercentage =
(props.snapThreshold / trackHeight) * (props.max - props.min);
// 检查是否接近预设值,增加吸附判定范围
for (const preset of props.presets) {
if (Math.abs(newValue - preset) < snapPercentage * 0.4) {
// 增加判定范围
// 进入吸附状态
setSnapLock(preset);
newValue = preset;
break;
}
}
// 检查是否接近记忆值
if (!isSnapping.value) {
for (const memValue of props.memorizedValues) {
if (Math.abs(newValue - memValue) < snapPercentage * 0.4) {
// 增加判定范围
// 进入吸附状态
setSnapLock(memValue);
newValue = memValue;
break;
}
}
}
}
setValue(newValue);
}
// 设置吸附锁定
function setSnapLock(value) {
if (!isSnapping.value) {
isSnapping.value = true;
snapLockValue.value = value;
// 添加视觉反馈:放大滑块
thumbScale.value = 1.3;
// 触感反馈:在支持的设备上触发振动
if (navigator.vibrate) {
navigator.vibrate(15); // 短暂振动15ms
}
// 延长锁定时间,增强吸附感
clearTimeout(snapLockTimeout.value);
snapLockTimeout.value = setTimeout(() => {
// 恢复滑块大小
thumbScale.value = 1;
clearSnapLock();
}, 500); // 500ms的吸附感觉比原来的300ms更强
}
}
// 清除吸附锁定
function clearSnapLock() {
isSnapping.value = false;
snapLockValue.value = null;
clearTimeout(snapLockTimeout.value);
snapLockTimeout.value = null;
thumbScale.value = 1; // 确保滑块恢复正常大小
}
function setValue(value) {
// 应用步进,确保即使通过点击预设值也会遵循步进
if (props.step > 0) {
value = roundToStep(value);
}
emit("update:modelValue", value);
}
function stopSliding() {
// 清除吸附锁定
clearSnapLock();
if (isSliding.value) {
// 如果确实进行了滑动,则发送滑动结束事件
if (slideMoved.value) {
emit("slide-end", { isSlide: true });
// 只有在滑动操作后才启动隐藏计时器
startHideTooltipTimer();
} else {
// 只是点击不是滑动不自动隐藏tooltip
emit("slide-end", { isSlide: false });
}
}
isSliding.value = false;
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", stopSliding);
}
// 处理点击事件
function handleClick(event) {
// 对于纯点击操作发出click事件
if (!slideMoved.value) {
clearHideTooltipTimer(); // 清除任何现有的隐藏计时器
updateTooltipVisibility(true); // 确保tooltip在点击时显示
emit("click");
// 点击不启动隐藏计时器
}
}
// 触摸事件处理
function startTouchSliding(event) {
isSliding.value = true;
slideMoved.value = false; // 重置滑动状态
updateTooltipVisibility(true);
updateValueFromTouchPosition(event);
// 滑动开始的触感反馈
if (navigator.vibrate) {
navigator.vibrate(10); // 更轻微的振动反馈
}
emit("slide-start");
window.addEventListener("touchmove", handleTouchMove, { passive: false });
window.addEventListener("touchend", stopTouchSliding);
}
function handleTouchMove(event) {
if (isSliding.value) {
slideMoved.value = true; // 标记已经发生了滑动
event.preventDefault(); // 阻止页面滚动
updateValueFromTouchPosition(event);
}
}
function updateValueFromTouchPosition(event) {
if (!event.touches || event.touches.length === 0) return;
const touch = event.touches[0];
const rect = sliderTrack.value.getBoundingClientRect();
const trackHeight = rect.height;
const posY = touch.clientY - rect.top;
// 计算相对位置并转换为min-max范围的值
let newValue = props.max - (posY / trackHeight) * (props.max - props.min);
// 边界检查
newValue = Math.max(props.min, Math.min(props.max, newValue));
// 应用步进
if (props.step > 0) {
newValue = roundToStep(newValue);
}
// 如果当前处于吸附锁定状态,并且移动不超过锁定阈值,则保持当前值不变
if (isSnapping.value && snapLockValue.value !== null) {
// 增加吸附力度:扩大保持吸附的阈值范围
if (
Math.abs(newValue - snapLockValue.value) <
(props.snapThreshold / trackHeight) * (props.max - props.min) * 1.5
) {
newValue = snapLockValue.value;
} else {
// 移动距离超过阈值,解除吸附锁定,并提供反馈
clearSnapLock();
if (navigator.vibrate) {
navigator.vibrate(8); // 解除吸附的轻微振动
}
}
} else {
// 检查是否可以进入吸附状态
const snapPercentage =
(props.snapThreshold / trackHeight) * (props.max - props.min);
// 检查是否接近预设值,增加吸附判定范围
for (const preset of props.presets) {
if (Math.abs(newValue - preset) < snapPercentage * 0.4) {
// 增加判定范围
// 进入吸附状态
setSnapLock(preset);
newValue = preset;
break;
}
}
// 检查是否接近记忆值
if (!isSnapping.value) {
for (const memValue of props.memorizedValues) {
if (Math.abs(newValue - memValue) < snapPercentage * 0.4) {
// 增加判定范围
// 进入吸附状态
setSnapLock(memValue);
newValue = memValue;
break;
}
}
}
}
setValue(newValue);
}
function stopTouchSliding() {
// 滑动结束时的反馈
if (slideMoved.value && navigator.vibrate) {
navigator.vibrate(12); // 滑动结束的振动反馈
}
// 清除吸附锁定
clearSnapLock();
if (isSliding.value) {
// 如果确实进行了滑动,则发送滑动结束事件
if (slideMoved.value) {
emit("slide-end", { isSlide: true });
// 只有在滑动操作后才启动隐藏计时器
startHideTooltipTimer();
} else {
// 只是点击不是滑动不自动隐藏tooltip
emit("slide-end", { isSlide: false });
}
}
isSliding.value = false;
window.removeEventListener("touchmove", handleTouchMove);
window.removeEventListener("touchend", stopTouchSliding);
}
// 添加点击外部关闭tooltip的处理函数
function handleOutsideClick(event) {
if (!props.closeOnOutsideClick || !tooltipVisible.value) return;
// 如果正在滑动,不处理外部点击事件
if (isSliding.value) return;
// 检查点击是否在slider组件外部
const containerEl = sliderTrack.value?.parentElement;
const tooltipEl = tooltip.value;
if (
containerEl &&
tooltipEl &&
!containerEl.contains(event.target) &&
!tooltipEl.contains(event.target)
) {
updateTooltipVisibility(false);
}
}
onMounted(() => {
// 添加全局点击事件监听
if (props.closeOnOutsideClick) {
// 使用 setTimeout 确保点击事件在其他处理程序之后执行
window.addEventListener("click", (e) =>
setTimeout(() => handleOutsideClick(e), 0)
);
}
});
onBeforeUnmount(() => {
// 清理事件监听器
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", stopSliding);
window.removeEventListener("touchmove", handleTouchMove);
window.removeEventListener("touchend", stopTouchSliding);
window.removeEventListener("click", handleOutsideClick);
// 清除任何剩余的计时器
clearHideTooltipTimer();
clearSnapLock();
});
</script>
<style scoped lang="less">
.vertical-slider-container {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
height: 150px;
position: relative;
// margin-top: 8px;
// margin-bottom: 0px;
}
.slider-track {
position: relative;
width: 32px;
height: 100%;
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
cursor: pointer;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
}
.slider-fill {
position: absolute;
bottom: 0;
width: 100%;
// background: linear-gradient(to top, #2196f3, #64b5f6);
border-radius: 4px;
}
.slider-thumb {
position: absolute;
width: 100%;
height: 16px;
background: #fff;
// border: 1px solid #2196f3;
border-radius: 3px;
cursor: grab;
box-shadow: 0 0px 4px rgba(0, 0, 0, 0.4);
transition: transform 0.15s cubic-bezier(0.175, 0.885, 0.32, 1.275); // 更平滑的动画效果
&:active {
cursor: grabbing;
}
}
// 添加iPad和移动设备的专有样式
@media (pointer: coarse) {
.slider-thumb {
height: 20px; // 在触摸设备上增加滑块尺寸,更容易点击
border-radius: 4px;
box-shadow: 0 0px 6px rgba(0, 0, 0, 0.5); // 更明显的阴影
}
.slider-track {
width: 40px; // 在触摸设备上增加宽度
}
.slider-notch {
width: 60%; // 增加刻度线宽度
height: 3px; // 增加刻度线高度
}
}
// 当设备支持悬停时的效果 (通常是桌面设备)
@media (hover: hover) {
.slider-thumb:hover {
box-shadow: 0 0 5px rgba(33, 150, 243, 0.4);
}
}
.slider-notch {
position: absolute;
left: 0;
width: 50%;
height: 2px;
background: #999;
border-radius: 2px;
transform: translateY(1px) translateX(50%);
transition: all 0.2s ease;
&.active {
background: #2196f3;
}
&:hover {
background: #333;
}
}
.slider-tooltip {
position: absolute;
left: calc(100% + 15px);
background: rgba(255, 255, 255, 0.95);
border-radius: 10px;
padding: 10px;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05);
min-width: 120px;
z-index: 10;
&::before {
content: "";
position: absolute;
left: -8px;
top: 50%;
transform: translateY(-50%);
border-width: 8px 8px 8px 0;
border-style: solid;
border-color: transparent rgba(255, 255, 255, 0.95) transparent transparent;
}
}
// 自定义滑块颜色
// .size-slider {
// .slider-fill {
// // background: linear-gradient(to top, #2196f3, #64b5f6);
// }
// .slider-thumb {
// border-color: #2196f3;
// }
// .slider-notch.active {
// background: #2196f3;
// }
// }
// .opacity-slider {
// .slider-fill {
// // background: linear-gradient(to top, #ff9800, #ffb74d);
// }
// .slider-thumb {
// border-color: #ff9800;
// }
// .slider-notch.active {
// background: #ff9800;
// }
// }
// 淡入淡出动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s, transform 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(-10px);
}
// 响应式调整
@media (max-height: 600px) {
.vertical-slider-container {
height: 120px;
}
}
@media (max-width: 768px) {
.slider-tooltip {
left: calc(100% + 10px);
min-width: 100px;
}
}
</style>