236 lines
7.2 KiB
Vue
236 lines
7.2 KiB
Vue
<!-- src/components/TimelineSlider.vue -->
|
||
<template>
|
||
<div class="timeline-slider" ref="sliderRef" @touchstart.prevent
|
||
@pointerdown.prevent="onPointerDown">
|
||
<!-- 底部轨道 -->
|
||
<div class="slider-track" :style="trackStyle">
|
||
<!-- 当 mode 为 segments 时,根据 segments 数据渲染各段颜色 -->
|
||
<template v-if="mode === 'segments' && segments.length">
|
||
<div v-for="(segment, index) in segments" :key="index"
|
||
class="slider-segment" :style="getSegmentStyle(segment)"></div>
|
||
</template>
|
||
<!-- 当 mode 为 ticks 时,渲染刻度标记 -->
|
||
<template v-if="mode === 'ticks' && markers.length">
|
||
<div v-for="(marker, index) in markers" :key="index"
|
||
class="slider-marker" :style="getMarkerStyle(marker)"></div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 可拖动的滑块 -->
|
||
<div class="slider-handle" :style="handleStyle" @touchstart.prevent
|
||
@pointerdown.prevent="onPointerDown">
|
||
<!-- 拖动时显示 tooltip -->
|
||
<div v-if="showTooltip" class="slider-tooltip" :style="tooltipStyle">
|
||
{{ formattedValue }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, watch } from 'vue';
|
||
import { format } from 'date-fns';
|
||
|
||
interface Segment {
|
||
start: number;
|
||
end: number;
|
||
active: boolean;
|
||
}
|
||
|
||
interface Marker {
|
||
time: number;
|
||
label?: string;
|
||
active?: boolean;
|
||
}
|
||
|
||
const props = defineProps({
|
||
minTime: { type: Number, required: true },
|
||
maxTime: { type: Number, required: true },
|
||
modelValue: { type: Number, required: true },
|
||
mode: { type: String, default: 'default' }, // 可传 'segments' 或 'ticks'
|
||
segments: { type: Array as () => Segment[], default: () => [] },
|
||
markers: { type: Array as () => Marker[], default: () => [] }
|
||
});
|
||
const emit = defineEmits(['update:modelValue', 'change']);
|
||
|
||
const sliderRef = ref<HTMLElement | null>(null);
|
||
const dragging = ref(false);
|
||
const internalValue = ref(props.modelValue);
|
||
const showTooltip = ref(false);
|
||
|
||
watch(() => props.modelValue, (newVal) => {
|
||
internalValue.value = newVal;
|
||
});
|
||
watch(internalValue, (newVal) => {
|
||
emit('update:modelValue', newVal);
|
||
});
|
||
|
||
// 格式化当前值显示为时间字符串
|
||
const formattedValue = computed(() => {
|
||
return format(new Date(internalValue.value), 'HH:mm:ss');
|
||
});
|
||
// 当前值在整个区间内的百分比
|
||
const percentage = computed(() => {
|
||
return ((internalValue.value - props.minTime) / (props.maxTime - props.minTime)) * 100;
|
||
});
|
||
const handleStyle = computed(() => {
|
||
return {
|
||
position: 'absolute' as any,
|
||
left: `${percentage.value}%`,
|
||
transform: 'translate(-50%, -50%)',
|
||
top: '50%',
|
||
width: '20px',
|
||
height: '20px',
|
||
borderRadius: '50%',
|
||
backgroundColor: '#4ADE80', // 绿色
|
||
border: '2px solid white',
|
||
boxShadow: '0 0 2px rgba(0,0,0,0.5)',
|
||
zIndex: 2,
|
||
cursor: 'pointer'
|
||
};
|
||
});
|
||
const trackStyle = computed(() => {
|
||
return {
|
||
position: 'absolute' as any,
|
||
top: '50%',
|
||
left: 0,
|
||
right: 0,
|
||
height: '6px',
|
||
backgroundColor: '#E5E7EB', // 灰色
|
||
transform: 'translateY(-50%)',
|
||
borderRadius: '3px'
|
||
};
|
||
});
|
||
// 根据每个 segment 计算其位置及宽度
|
||
const getSegmentStyle = (segment: Segment) => {
|
||
const startPercent = ((segment.start - props.minTime) / (props.maxTime - props.minTime)) * 100;
|
||
const endPercent = ((segment.end - props.minTime) / (props.maxTime - props.minTime)) * 100;
|
||
const widthPercent = endPercent - startPercent;
|
||
return {
|
||
position: 'absolute' as any,
|
||
left: `${startPercent}%`,
|
||
width: `${widthPercent}%`,
|
||
height: '100%',
|
||
backgroundColor: segment.active ? '#4ADE80' : '#E5E7EB',
|
||
borderLeft: '1px solid #E5E7EB',
|
||
borderRight: '1px solid #E5E7EB',
|
||
};
|
||
};
|
||
// 根据 marker 计算其位置
|
||
const getMarkerStyle = (marker: Marker) => {
|
||
const pos = ((marker.time - props.minTime) / (props.maxTime - props.minTime)) * 100;
|
||
return {
|
||
position: 'absolute' as any,
|
||
left: `${pos}%`,
|
||
top: '50%',
|
||
width: '2px',
|
||
height: '100%',
|
||
backgroundColor: marker.active ? '#4ADE80' : '#9CA3AF',
|
||
transform: 'translate(-50%, -50%)'
|
||
};
|
||
};
|
||
|
||
const tooltipStyle = computed(() => {
|
||
return {
|
||
position: 'absolute' as any,
|
||
bottom: '100%', // 显示在滑块上方
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
marginBottom: '8px',
|
||
padding: '4px 8px',
|
||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||
color: 'white',
|
||
borderRadius: '4px',
|
||
fontSize: '12px',
|
||
whiteSpace: 'nowrap'
|
||
};
|
||
});
|
||
|
||
const pointerMoveHandler = (event: PointerEvent) => {
|
||
if (!dragging.value || !sliderRef.value) return;
|
||
const rect = sliderRef.value.getBoundingClientRect();
|
||
let x = event.clientX - rect.left;
|
||
x = Math.max(0, Math.min(x, rect.width));
|
||
const newValue = props.minTime + (x / rect.width) * (props.maxTime - props.minTime);
|
||
internalValue.value = newValue;
|
||
};
|
||
|
||
const pointerUpHandler = () => {
|
||
dragging.value = false;
|
||
showTooltip.value = false;
|
||
window.removeEventListener('pointermove', pointerMoveHandler);
|
||
window.removeEventListener('pointerup', pointerUpHandler);
|
||
|
||
// 根据当前模式吸附到最近的点上
|
||
const snapped = getSnapValue(internalValue.value);
|
||
internalValue.value = snapped;
|
||
emit('change', snapped);
|
||
};
|
||
|
||
/**
|
||
* 根据当前的 internalValue 计算吸附(snap)后的值:
|
||
* - 若 mode 为 'ticks' 且 markers 不为空,则吸附到最近的 marker.time;
|
||
* - 若 mode 为 'segments' 且存在 active 的 segment,则吸附到最近 active segment 的中点;
|
||
* - 否则直接返回当前值。
|
||
*/
|
||
const getSnapValue = (value: number): number => {
|
||
if (props.mode === 'ticks' && props.markers.length > 0) {
|
||
let closest = props.markers[0].time;
|
||
let minDiff = Math.abs(value - props.markers[0].time);
|
||
for (const marker of props.markers) {
|
||
const diff = Math.abs(value - marker.time);
|
||
if (diff < minDiff) {
|
||
minDiff = diff;
|
||
closest = marker.time;
|
||
}
|
||
}
|
||
return closest;
|
||
} else if (props.mode === 'segments' && props.segments.length > 0) {
|
||
// 遍历所有 active 的 segments,计算其 start 和 end 的中点,并返回与 value 最近的那个中点
|
||
let closestMidpoint: number | null = null;
|
||
let minDiff = Infinity;
|
||
for (const segment of props.segments) {
|
||
if (segment.active) {
|
||
const midpoint = (segment.start + segment.end) / 2;
|
||
const diff = Math.abs(value - midpoint);
|
||
if (diff < minDiff) {
|
||
minDiff = diff;
|
||
closestMidpoint = midpoint;
|
||
}
|
||
}
|
||
}
|
||
if (closestMidpoint !== null) {
|
||
return closestMidpoint;
|
||
}
|
||
}
|
||
return value;
|
||
};
|
||
|
||
|
||
const onPointerDown = (event: PointerEvent) => {
|
||
event.stopPropagation();
|
||
dragging.value = true;
|
||
showTooltip.value = true;
|
||
window.addEventListener('pointermove', pointerMoveHandler);
|
||
window.addEventListener('pointerup', pointerUpHandler);
|
||
if (!dragging.value || !sliderRef.value) return;
|
||
const rect = sliderRef.value.getBoundingClientRect();
|
||
let x = event.clientX - rect.left;
|
||
x = Math.max(0, Math.min(x, rect.width));
|
||
const newValue = props.minTime + (x / rect.width) * (props.maxTime - props.minTime);
|
||
internalValue.value = newValue;
|
||
};
|
||
|
||
onMounted(() => {
|
||
// 初始化操作(如有需要)……
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.timeline-slider {
|
||
position: relative;
|
||
height: 40px;
|
||
user-select: none;
|
||
touch-action: none;
|
||
}
|
||
</style> |