Files
aigen/components/Thumbwheel.vue
T

227 lines
7.1 KiB
Vue

<template>
<div class="w-[6.5rem] min-w-0" :class="disabled ? 'opacity-50' : ''">
<div v-if="label" class="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-zinc-500">{{ label }}</div>
<div
class="overflow-hidden rounded-2xl border bg-zinc-950"
:class="locked ? 'border-amber-300/35' : 'border-white/10'"
>
<div class="flex items-stretch border-b border-white/5">
<div class="min-w-0 flex-1">
<SegmentDigits :value="display" />
</div>
<button
type="button"
class="flex w-7 shrink-0 items-center justify-center border-l transition"
:class="locked
? 'border-amber-300/25 bg-amber-400/10 text-amber-300'
: 'border-white/5 text-zinc-600 hover:text-zinc-300'"
:aria-pressed="locked"
:aria-label="locked ? `Unlock ${dialName}` : `Lock ${dialName}`"
:title="locked ? 'Unlock — wheel and scroll will change this again' : 'Lock — keep this value while you scroll'"
@click.stop="toggleLock"
@pointerdown.stop
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" class="h-3.5 w-3.5" aria-hidden="true">
<rect x="3.2" y="7.2" width="9.6" height="7" rx="1.4" />
<path
v-if="locked"
stroke-linecap="round"
d="M5.2 7.2V5.1a2.8 2.8 0 015.6 0v2.1"
/>
<path
v-else
stroke-linecap="round"
d="M5.2 7.2V5.1a2.8 2.8 0 015.2-1.4"
/>
</svg>
</button>
</div>
<div
ref="root"
class="thumbwheel relative w-full select-none"
:class="[
compact ? 'h-28' : 'h-36',
frozen ? 'pointer-events-none cursor-default' : 'cursor-ns-resize'
]"
role="slider"
:aria-label="ariaLabel || label || 'Value'"
:aria-valuemin="min"
:aria-valuemax="max"
:aria-valuenow="model"
:aria-disabled="disabled || locked"
tabindex="0"
@pointerdown="onPointerDown"
@keydown="onKey"
>
<div
class="thumbwheel-drum pointer-events-none absolute inset-y-1 left-1 right-8 overflow-hidden rounded-xl"
:class="locked ? 'opacity-40' : ''"
:style="{ backgroundPositionY: `${drumShift}px` }"
/>
<div class="pointer-events-none absolute inset-y-2 right-2.5 w-2.5 rounded-full bg-zinc-900 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]">
<span
class="absolute left-0 right-0 h-0.5 rounded-full bg-amber-300 shadow-[0_0_10px_rgba(251,191,36,0.85)]"
:style="{ top: `calc(${100 - pct}% - 1px)` }"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
min: number
max: number
step?: number
fineStep?: number
coarseStep?: number
label?: string
ariaLabel?: string
lockKey?: string
disabled?: boolean
compact?: boolean
digits?: number
}>(), {
step: 0.1,
fineStep: 0.1,
coarseStep: 1,
label: '',
ariaLabel: '',
lockKey: '',
disabled: false,
compact: false,
digits: 1
})
const model = defineModel<number>({ required: true })
const root = ref<HTMLElement | null>(null)
const locked = ref(false)
const dialName = computed(() => props.label || props.ariaLabel || 'dial')
const storeKey = computed(() => {
const id = (props.lockKey || props.label || props.ariaLabel || '').trim().toLowerCase()
return id ? `aigen-thumb-lock:${id}` : ''
})
const frozen = computed(() => props.disabled || locked.value)
const span = computed(() => Math.max(props.max - props.min, 0.0001))
const pct = computed(() => ((clamp(model.value) - props.min) / span.value) * 100)
const display = computed(() => format(model.value))
const drumShift = computed(() => Math.round((pct.value / 100) * 72))
function readLock() {
if (!storeKey.value || typeof localStorage === 'undefined') return
try {
locked.value = localStorage.getItem(storeKey.value) === '1'
} catch {
locked.value = false
}
}
function toggleLock() {
locked.value = !locked.value
if (!storeKey.value || typeof localStorage === 'undefined') return
try {
localStorage.setItem(storeKey.value, locked.value ? '1' : '0')
} catch { /* ignore */ }
}
function clamp(value: number) {
if (!Number.isFinite(value)) return props.min
const step = props.step || 0.1
const snapped = Math.round(value / step) * step
const places = step < 0.1 ? 2 : 1
const rounded = Number(snapped.toFixed(places))
return Math.min(props.max, Math.max(props.min, rounded))
}
function format(value: number) {
const n = clamp(value)
if (props.digits === 0) return String(Math.round(n))
return n.toFixed(props.digits).replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')
}
function nudge(delta: number) {
model.value = clamp(model.value + delta)
}
function onWheel(event: WheelEvent) {
if (frozen.value) return
event.preventDefault()
const step = event.shiftKey ? props.coarseStep : props.fineStep
nudge(event.deltaY < 0 ? step : -step)
}
function onKey(event: KeyboardEvent) {
if (frozen.value) return
const step = event.shiftKey ? props.coarseStep : props.fineStep
if (event.key === 'ArrowUp' || event.key === 'ArrowRight') {
event.preventDefault()
nudge(step)
} else if (event.key === 'ArrowDown' || event.key === 'ArrowLeft') {
event.preventDefault()
nudge(-step)
} else if (event.key === 'Home') {
event.preventDefault()
model.value = props.min
} else if (event.key === 'End') {
event.preventDefault()
model.value = props.max
}
}
function onPointerDown(event: PointerEvent) {
if (frozen.value || event.button !== 0) return
const el = root.value
if (!el) return
event.preventDefault()
el.setPointerCapture(event.pointerId)
const height = el.clientHeight || 1
const startY = event.clientY
const startValue = model.value
const onMove = (move: PointerEvent) => {
const delta = startY - move.clientY
model.value = clamp(startValue + (delta / height) * span.value)
}
const onUp = () => {
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', onUp)
el.removeEventListener('pointercancel', onUp)
}
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', onUp)
el.addEventListener('pointercancel', onUp)
}
onMounted(() => {
readLock()
const el = root.value
if (!el) return
el.addEventListener('wheel', onWheel, { passive: false })
onBeforeUnmount(() => el.removeEventListener('wheel', onWheel))
})
watch(storeKey, readLock)
</script>
<style scoped>
.thumbwheel {
touch-action: none;
}
.thumbwheel-drum {
background-image:
repeating-linear-gradient(
to bottom,
rgba(82, 82, 91, 0.95) 0 2px,
rgba(24, 24, 27, 1) 2px 7px,
rgba(63, 63, 70, 0.9) 7px 9px,
rgba(9, 9, 11, 1) 9px 14px
);
background-size: 100% 14px;
box-shadow:
inset 0 18px 20px rgba(0, 0, 0, 0.55),
inset 0 -18px 20px rgba(0, 0, 0, 0.55),
inset 12px 0 16px rgba(255, 255, 255, 0.04);
}
</style>