|
1 | 1 | <script setup lang="ts"> |
2 | 2 | import type { ClicksContext } from '@slidev/types' |
3 | 3 | import { clamp, range } from '@antfu/utils' |
4 | | -import { computed } from 'vue' |
| 4 | +import { computed, ref } from 'vue' |
5 | 5 | import { CLICKS_MAX } from '../constants' |
6 | 6 |
|
7 | 7 | const props = withDefaults(defineProps<{ |
8 | 8 | clicksContext: ClicksContext |
9 | 9 | readonly?: boolean |
10 | 10 | active?: boolean |
| 11 | + resettable?: boolean |
| 12 | + compact?: boolean |
| 13 | + attached?: boolean |
11 | 14 | }>(), { |
12 | 15 | active: true, |
13 | 16 | }) |
14 | 17 |
|
| 18 | +const emit = defineEmits<{ |
| 19 | + (type: 'activate'): void |
| 20 | + (type: 'reset'): void |
| 21 | +}>() |
| 22 | +
|
15 | 23 | const total = computed(() => props.clicksContext.total) |
16 | 24 | const start = computed(() => clamp(0, props.clicksContext.clicksStart, total.value)) |
17 | 25 | const length = computed(() => total.value - start.value + 1) |
18 | 26 | const current = computed({ |
19 | 27 | get() { |
| 28 | + if (props.resettable && !props.active) |
| 29 | + return -1 |
20 | 30 | return props.clicksContext.current > total.value ? -1 : props.clicksContext.current |
21 | 31 | }, |
22 | 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') |
23 | 40 | // eslint-disable-next-line vue/no-mutating-props |
24 | 41 | props.clicksContext.current = value |
25 | 42 | }, |
26 | 43 | }) |
27 | 44 |
|
| 45 | +const isReset = computed(() => props.resettable && current.value < 0) |
28 | 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 | +} |
29 | 75 |
|
30 | | -function onMousedown() { |
| 76 | +function onPointerDown(event: PointerEvent) { |
31 | 77 | if (props.readonly) |
32 | 78 | return |
33 | | - if (current.value < 0 || current.value > total.value) |
34 | | - current.value = 0 |
| 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) |
35 | 92 | } |
36 | 93 | </script> |
37 | 94 |
|
38 | 95 | <template> |
39 | 96 | <div |
40 | | - class="flex gap-1 items-center select-none" |
| 97 | + class="flex gap-1 select-none" |
41 | 98 | :title="`Clicks in this slide: ${length}`" |
42 | | - :class="length && props.clicksContext.isMounted ? '' : 'op50'" |
| 99 | + :class="[attached ? 'items-end' : 'items-center', length && props.clicksContext.isMounted ? '' : 'op50']" |
43 | 100 | > |
44 | | - <div class="flex gap-0.2 items-center min-w-16 font-mono mr1"> |
45 | | - <div class="i-carbon:cursor-1 text-sm op50" /> |
| 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' : ''" /> |
46 | 106 | <template v-if="current >= 0 && current !== CLICKS_MAX && active"> |
47 | | - <div flex-auto /> |
48 | | - <span text-primary>{{ current }}</span> |
49 | | - <span op25 text-sm>/</span> |
50 | | - <span op50 text-sm>{{ total }}</span> |
| 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> |
51 | 113 | </template> |
52 | 114 | <div |
53 | 115 | v-else |
54 | | - op50 flex-auto pl1 |
| 116 | + op50 |
| 117 | + :class="compact ? '' : 'flex-auto pl1'" |
55 | 118 | > |
56 | | - {{ total }} |
| 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> |
57 | 123 | </div> |
58 | 124 | </div> |
59 | 125 | <div |
60 | | - relative flex-auto h5 font-mono flex="~" |
| 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" |
61 | 134 | > |
62 | 135 | <div |
63 | 136 | v-for="i of clicksRange" :key="i" |
64 | 137 | border="y main" of-hidden relative |
65 | 138 | :class="[ |
66 | | - i === 0 ? 'rounded-l border-l' : '', |
67 | | - i === total ? 'rounded-r border-r' : '', |
| 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' : '', |
68 | 144 | ]" |
69 | 145 | :style="{ width: length > 0 ? `${1 / length * 100}%` : '100%' }" |
70 | 146 | > |
71 | 147 | <div |
72 | 148 | absolute inset-0 |
73 | 149 | :class="(i <= current && active) ? 'bg-primary op15' : ''" |
74 | 150 | /> |
| 151 | + <div |
| 152 | + v-if="+i === +current && active" |
| 153 | + absolute inset-y-0 right-0 w-0.5 bg-primary z-1 |
| 154 | + /> |
75 | 155 | <div |
76 | 156 | :class="[ |
77 | | - (+i === +current && active) ? 'text-primary font-bold op100 border-primary' : 'op30 border-main', |
78 | | - i === 0 ? 'rounded-l' : '', |
79 | | - i === total ? 'rounded-r' : 'border-r-2', |
| 157 | + (+i === +current && active) ? 'text-primary font-bold op100' : 'op30', |
| 158 | + i !== total ? 'border-r-2 border-main' : '', |
80 | 159 | ]" |
81 | 160 | w-full h-full text-xs flex items-center justify-center z-1 |
82 | 161 | > |
83 | 162 | {{ i }} |
84 | 163 | </div> |
85 | 164 | </div> |
86 | | - <input |
87 | | - v-model="current" |
88 | | - class="range" |
89 | | - type="range" :min="start" :max="total" :step="1" |
90 | | - absolute inset-0 z-label op0 |
91 | | - :class="readonly ? 'pointer-events-none' : ''" |
92 | | - :style="{ '--thumb-width': `${1 / (length + 1) * 100}%` }" |
93 | | - @mousedown="onMousedown" |
94 | | - @focus="event => (event.currentTarget as HTMLElement)?.blur()" |
95 | | - > |
96 | 165 | </div> |
97 | 166 | </div> |
98 | 167 | </template> |
99 | | - |
100 | | -<style scoped> |
101 | | -.range { |
102 | | - -webkit-appearance: none; |
103 | | - appearance: none; |
104 | | - background: transparent; |
105 | | -} |
106 | | -.range::-webkit-slider-thumb { |
107 | | - -webkit-appearance: none; |
108 | | - height: 100%; |
109 | | - width: var(--thumb-width, 0.5rem); |
110 | | -} |
111 | | -
|
112 | | -.range::-moz-range-thumb { |
113 | | - height: 100%; |
114 | | - width: var(--thumb-width, 0.5rem); |
115 | | -} |
116 | | -</style> |
|
0 commit comments