| 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 |