返回 slidev
ClicksSlider.vue
根目录 / packages / client / internals / ClicksSlider.vue
1 <script setup lang="ts">
2 import type { ClicksContext } from '@slidev/types'
3 import { clamp, range } from '@antfu/utils'
4 import { computed, ref } from 'vue'
5 import { CLICKS_MAX } from '../constants'
6
7 const props = withDefaults(defineProps<{
8 clicksContext: ClicksContext
9 readonly?: boolean
10 active?: boolean
11 resettable?: boolean
12 compact?: boolean
13 attached?: boolean
14 }>(), {
15 active: true,
16 })
17
18 const emit = defineEmits<{
19 (type: 'activate'): void
20 (type: 'reset'): void
21 }>()
22
23 const total = computed(() => props.clicksContext.total)
24 const start = computed(() => clamp(0, props.clicksContext.clicksStart, total.value))
25 const length = computed(() => total.value - start.value + 1)
26 const current = computed({
27 get() {
28 if (props.resettable && !props.active)
29 return -1
30 return props.clicksContext.current > total.value ? -1 : props.clicksContext.current
31 },
32 set(value: number) {
33 if (props.resettable && value < 0) {
34 emit('reset')
35 // eslint-disable-next-line vue/no-mutating-props
36 props.clicksContext.current = CLICKS_MAX
37 return
38 }
39 emit('activate')
40 // eslint-disable-next-line vue/no-mutating-props
41 props.clicksContext.current = value
42 },
43 })
44
45 const isReset = computed(() => props.resettable && current.value < 0)
46 const clicksRange = computed(() => range(start.value, total.value + 1))
47 const sliderEl = ref<HTMLElement>()
48 let pointerDown: { id: number, x: number, y: number } | undefined
49
50 function getPointerRatio(event: PointerEvent) {
51 const rect = sliderEl.value!.getBoundingClientRect()
52 return (event.clientX - rect.left) / Math.max(1, rect.width)
53 }
54
55 // Presses snap to a cell; drags switch only after crossing half a cell.
56 function setCurrentFromPointer(event: PointerEvent, snap: boolean) {
57 if (props.readonly || !sliderEl.value || (!snap && !(event.buttons & 1)))
58 return
59 const ratio = getPointerRatio(event)
60 // In resettable mode, dragging left of the rail restores the inactive state.
61 if (props.resettable && ratio < 0) {
62 current.value = -1
63 return
64 }
65 // Keep press-at-right-edge inside the last cell.
66 const position = clamp(0, ratio, snap ? 0.999999 : 1) * length.value
67 const currentOffset = clamp(0, current.value - start.value, length.value - 1)
68 let next = snap ? start.value + Math.floor(position) : current.value
69 if (!snap && position >= currentOffset + 1.5)
70 next = start.value + Math.floor(position - 0.5)
71 else if (!snap && position < currentOffset - 0.5)
72 next = start.value + Math.ceil(position - 0.5)
73 current.value = clamp(start.value, next, total.value)
74 }
75
76 function onPointerDown(event: PointerEvent) {
77 if (props.readonly)
78 return
79 sliderEl.value?.setPointerCapture(event.pointerId)
80 pointerDown = { id: event.pointerId, x: event.clientX, y: event.clientY }
81 setCurrentFromPointer(event, true)
82 }
83
84 function onPointerMove(event: PointerEvent) {
85 if (pointerDown?.id === event.pointerId) {
86 // Treat tiny movement after pointerdown as part of the click.
87 if (Math.abs(event.clientX - pointerDown.x) <= 3 && Math.abs(event.clientY - pointerDown.y) <= 3)
88 return
89 pointerDown = undefined
90 }
91 setCurrentFromPointer(event, false)
92 }
93 </script>
94
95 <template>
96 <div
97 class="flex gap-1 select-none"
98 :title="`Clicks in this slide: ${length}`"
99 :class="[attached ? 'items-end' : 'items-center', length && props.clicksContext.isMounted ? '' : 'op50']"
100 >
101 <div
102 class="flex items-center font-mono"
103 :class="[compact ? 'gap-1 min-w-0 mr0' : 'gap-0.2 min-w-16 mr1', attached ? 'h-[22px]' : '']"
104 >
105 <div class="i-carbon:cursor-1 text-sm op50" :class="compact ? 'ml-1' : ''" />
106 <template v-if="current >= 0 && current !== CLICKS_MAX && active">
107 <div v-if="!compact" flex-auto />
108 <span>
109 <span text-primary>{{ current }}</span>
110 <span op25 text-sm>/</span>
111 <span op50 text-sm>{{ total }}</span>
112 </span>
113 </template>
114 <div
115 v-else
116 op50
117 :class="compact ? '' : 'flex-auto pl1'"
118 >
119 <span
120 :class="compact ? 'inline-block text-center' : ''"
121 :style="compact ? { width: `${String(total).length * 2 + 1}ch`, marginLeft: '-0.25ch' } : undefined"
122 >{{ total }}</span>
123 </div>
124 </div>
125 <div
126 ref="sliderEl"
127 relative flex-auto font-mono flex="~"
128 touch-none
129 :class="[attached ? 'h-[22px]' : 'h5', isReset ? 'op80' : '']"
130 @pointerdown.capture="onPointerDown"
131 @pointermove="onPointerMove"
132 @pointerup="pointerDown = undefined"
133 @pointercancel="pointerDown = undefined"
134 >
135 <div
136 v-for="i of clicksRange" :key="i"
137 border="y main" of-hidden relative
138 :class="[
139 i === 0 ? 'border-l' : '',
140 i === 0 ? attached ? 'rounded-tl' : 'rounded-l' : '',
141 i === total ? 'border-r' : '',
142 i === total && +i !== +current ? attached ? 'rounded-tr' : 'rounded-r' : '',
143 attached ? 'border-b-0' : '',
144 ]"
145 :style="{ width: length > 0 ? `${1 / length * 100}%` : '100%' }"
146 >
147 <div
148 absolute inset-0
149 :class="(i <= current && active) ? 'bg-primary op15' : ''"
150 />
151 <div
152 v-if="+i === +current && active"
153 absolute inset-y-0 right-0 w-0.5 bg-primary z-1
154 />
155 <div
156 :class="[
157 (+i === +current && active) ? 'text-primary font-bold op100' : 'op30',
158 i !== total ? 'border-r-2 border-main' : '',
159 ]"
160 w-full h-full text-xs flex items-center justify-center z-1
161 >
162 {{ i }}
163 </div>
164 </div>
165 </div>
166 </div>
167 </template>
168
168 lines Plain Text