198 lines
4.8 KiB
Vue
198 lines
4.8 KiB
Vue
<template>
|
||
<div class="slider-wrapper" :style="wrapperStyle">
|
||
|
||
<div class="slider-inner" ref="containerRef" :style="innerStyle">
|
||
|
||
<div class="track-slot"></div>
|
||
|
||
<div
|
||
class="knob"
|
||
:style="knobStyle"
|
||
@mousedown="startDrag"
|
||
@touchstart.prevent="startDrag"
|
||
>
|
||
<div class="knob-face"></div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch } from 'vue';
|
||
|
||
const props = defineProps({
|
||
modelValue: {
|
||
type: Number,
|
||
default: 0
|
||
},
|
||
// 新增 scale 属性,默认为 1 (原始大小)
|
||
scale: {
|
||
type: Number,
|
||
default: 1.0
|
||
}
|
||
});
|
||
|
||
const emit = defineEmits(['update:modelValue', 'change']);
|
||
|
||
// --- 内部常量定义 (基于原始设计尺寸) ---
|
||
const ORIGINAL_WIDTH = 40; // 原始宽度 px
|
||
const ORIGINAL_HEIGHT = 150; // 原始高度 px
|
||
const STEPS = 4;
|
||
const STEP_DISTANCE = 40;
|
||
const KNOB_HEIGHT = 24;
|
||
const TRACK_PADDING_TOP = 2;
|
||
|
||
// --- 状态 ---
|
||
const currentIndex = ref(props.modelValue);
|
||
|
||
watch(() => props.modelValue, (newVal) => {
|
||
currentIndex.value = newVal;
|
||
});
|
||
|
||
// --- 样式计算 ---
|
||
|
||
// 1. 外层容器样式:计算缩放后实际占用的宽高
|
||
const wrapperStyle = computed(() => ({
|
||
width: `${ORIGINAL_WIDTH * props.scale}px`,
|
||
height: `${ORIGINAL_HEIGHT * props.scale}px`,
|
||
}));
|
||
|
||
// 2. 内层容器样式:固定原始尺寸,使用 scale 缩放
|
||
const innerStyle = computed(() => ({
|
||
width: `${ORIGINAL_WIDTH}px`,
|
||
height: `${ORIGINAL_HEIGHT}px`,
|
||
transform: `scale(${props.scale})`,
|
||
transformOrigin: 'top left' // 关键:从左上角开始缩放,确保与外层容器对齐
|
||
}));
|
||
|
||
// 3. 旋钮位置样式
|
||
const knobStyle = computed(() => {
|
||
const topOffset = TRACK_PADDING_TOP + (currentIndex.value * STEP_DISTANCE);
|
||
return {
|
||
transform: `translateY(${topOffset}px)`
|
||
};
|
||
});
|
||
|
||
// --- 拖拽逻辑 (含缩放修正) ---
|
||
let isDragging = false;
|
||
let startMouseY = 0;
|
||
let startKnobTop = 0;
|
||
|
||
const startDrag = (e) => {
|
||
isDragging = true;
|
||
startMouseY = e.clientY || e.touches[0].clientY;
|
||
startKnobTop = TRACK_PADDING_TOP + (currentIndex.value * STEP_DISTANCE);
|
||
|
||
window.addEventListener('mousemove', onDrag);
|
||
window.addEventListener('mouseup', stopDrag);
|
||
window.addEventListener('touchmove', onDrag, { passive: false });
|
||
window.addEventListener('touchend', stopDrag);
|
||
};
|
||
|
||
const onDrag = (e) => {
|
||
if (!isDragging) return;
|
||
if (e.cancelable) e.preventDefault();
|
||
|
||
const currentMouseY = e.clientY || e.touches[0].clientY;
|
||
|
||
// [关键修正]
|
||
// 鼠标在屏幕上移动的距离 (screen delta)
|
||
const screenDeltaY = currentMouseY - startMouseY;
|
||
|
||
// 转换为组件内部的距离 (internal delta)
|
||
// 比如:如果缩放是 0.5,屏幕移动 10px,相当于内部移动了 20px
|
||
const internalDeltaY = screenDeltaY / props.scale;
|
||
|
||
let newRawTop = startKnobTop + internalDeltaY;
|
||
|
||
// 计算吸附
|
||
let relativeTop = newRawTop - TRACK_PADDING_TOP;
|
||
let newIndex = Math.round(relativeTop / STEP_DISTANCE);
|
||
|
||
const maxIndex = STEPS - 1;
|
||
if (newIndex < 0) newIndex = 0;
|
||
if (newIndex > maxIndex) newIndex = maxIndex;
|
||
|
||
if (newIndex !== currentIndex.value) {
|
||
currentIndex.value = newIndex;
|
||
emit('update:modelValue', newIndex);
|
||
emit('change', newIndex);
|
||
}
|
||
};
|
||
|
||
const stopDrag = () => {
|
||
isDragging = false;
|
||
window.removeEventListener('mousemove', onDrag);
|
||
window.removeEventListener('mouseup', stopDrag);
|
||
window.removeEventListener('touchmove', onDrag);
|
||
window.removeEventListener('touchend', stopDrag);
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 样式部分主要针对 slider-inner 内部
|
||
保持原本的尺寸定义即可,缩放由内联样式 style 处理
|
||
*/
|
||
|
||
.slider-wrapper {
|
||
/* 防止溢出裁剪阴影 */
|
||
overflow: visible;
|
||
/* 禁止选中 */
|
||
user-select: none;
|
||
touch-action: none;
|
||
/* 方便父级布局 */
|
||
display: inline-block;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.slider-inner {
|
||
position: relative;
|
||
/* 这里的宽高现在由 innerStyle 动态控制,但写上默认值是个好习惯 */
|
||
width: 40px;
|
||
height: 150px;
|
||
}
|
||
|
||
.track-slot {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 8px;
|
||
background-color: #1a1a1a;
|
||
border-radius: 4px;
|
||
box-shadow:
|
||
inset 1px 1px 3px rgba(0,0,0,0.7),
|
||
inset -1px -1px 1px rgba(255,255,255,0.1);
|
||
}
|
||
|
||
.knob {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 50%;
|
||
margin-left: -23px;
|
||
width: 46px;
|
||
height: 24px;
|
||
z-index: 10;
|
||
cursor: grab;
|
||
transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1);
|
||
backface-visibility: hidden; /* 优化缩放后的渲染 */
|
||
}
|
||
|
||
.knob:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.knob-face {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(to bottom, #ffffff 0%, #e4e4e4 100%);
|
||
border-radius: 4px;
|
||
box-shadow:
|
||
inset 0 1px 0 rgba(255,255,255,1),
|
||
inset 0 -1px 0 rgba(0,0,0,0.1),
|
||
0 3px 6px rgba(0,0,0,0.35),
|
||
0 0 0 1px rgba(0,0,0,0.08);
|
||
}
|
||
</style> |