0.25 and 0.35 both looked like 0.3, so snofs strengths on stills did not match the wheels. Co-authored-by: Cursor <cursoragent@cursor.com>
306 lines
9.6 KiB
Vue
306 lines
9.6 KiB
Vue
<template>
|
|
<div class="min-w-0" :class="[disabled ? 'opacity-50' : '', unit ? 'w-[7.25rem]' : 'w-[6.5rem]']">
|
|
<div class="mb-1.5 min-h-[1.25rem] text-[10px] font-medium uppercase tracking-wider text-zinc-500">{{ label || '\u00a0' }}</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">
|
|
<input
|
|
v-if="editing"
|
|
ref="editInput"
|
|
v-model="draft"
|
|
type="text"
|
|
inputmode="decimal"
|
|
class="h-11 w-full bg-transparent px-2 text-center font-mono text-lg text-amber-300 outline-none"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
@blur="commitEdit"
|
|
@keydown.enter.prevent="commitEdit"
|
|
@keydown.escape.prevent="cancelEdit"
|
|
@pointerdown.stop
|
|
>
|
|
<button
|
|
v-else-if="editable"
|
|
type="button"
|
|
class="block w-full cursor-text"
|
|
:aria-label="`Type ${dialName}`"
|
|
@click="startEdit"
|
|
@pointerdown.stop
|
|
>
|
|
<SegmentDigits :value="display" />
|
|
</button>
|
|
<SegmentDigits v-else :value="display" />
|
|
</div>
|
|
<span
|
|
v-if="unit"
|
|
class="flex items-end px-1.5 pb-2.5 text-[10px] font-medium uppercase tracking-wider text-zinc-500"
|
|
>{{ unit }}</span>
|
|
<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
|
|
editable?: boolean
|
|
unit?: string
|
|
dragRange?: number
|
|
}>(), {
|
|
step: 0.1,
|
|
coarseStep: 1,
|
|
label: '',
|
|
ariaLabel: '',
|
|
lockKey: '',
|
|
disabled: false,
|
|
compact: false,
|
|
digits: 1,
|
|
editable: true,
|
|
unit: '',
|
|
dragRange: 0
|
|
})
|
|
|
|
const model = defineModel<number>({ required: true })
|
|
const root = ref<HTMLElement | null>(null)
|
|
const editInput = ref<HTMLInputElement | null>(null)
|
|
const editing = ref(false)
|
|
const draft = ref('')
|
|
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 tick = computed(() => props.step || 0.1)
|
|
const fine = computed(() => (Number.isFinite(props.fineStep) && Number(props.fineStep) > 0 ? Number(props.fineStep) : tick.value))
|
|
const places = computed(() => Math.max(props.digits, stepDecimalPlaces(tick.value)))
|
|
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 stepDecimalPlaces(step: number) {
|
|
if (!Number.isFinite(step) || step <= 0) return 1
|
|
if (step >= 1) return 0
|
|
const text = step.toFixed(8).replace(/0+$/, '')
|
|
const dot = text.indexOf('.')
|
|
return dot >= 0 ? text.length - dot - 1 : 0
|
|
}
|
|
|
|
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 = tick.value
|
|
const snapped = Math.round(value / step) * step
|
|
const rounded = Number(snapped.toFixed(places.value))
|
|
return Math.min(props.max, Math.max(props.min, rounded))
|
|
}
|
|
|
|
function format(value: number) {
|
|
const n = clamp(value)
|
|
if (places.value <= 0) return String(Math.round(n))
|
|
return n.toFixed(places.value).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 : fine.value
|
|
nudge(event.deltaY < 0 ? step : -step)
|
|
}
|
|
|
|
function onKey(event: KeyboardEvent) {
|
|
if (frozen.value) return
|
|
const step = event.shiftKey ? props.coarseStep : fine.value
|
|
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 coarsePointer(event: PointerEvent) {
|
|
if (event.pointerType === 'touch') return true
|
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
|
|
return window.matchMedia('(pointer: coarse)').matches
|
|
}
|
|
|
|
function dragGain(event: PointerEvent) {
|
|
return coarsePointer(event) ? 0.25 : 1
|
|
}
|
|
|
|
function startEdit() {
|
|
if (!props.editable || props.disabled) return
|
|
draft.value = format(model.value)
|
|
editing.value = true
|
|
nextTick(() => {
|
|
editInput.value?.focus()
|
|
editInput.value?.select()
|
|
})
|
|
}
|
|
|
|
function commitEdit() {
|
|
if (!editing.value) return
|
|
const parsed = Number.parseFloat(draft.value.replace(',', '.').trim())
|
|
if (Number.isFinite(parsed)) model.value = clamp(parsed)
|
|
editing.value = false
|
|
}
|
|
|
|
function cancelEdit() {
|
|
editing.value = false
|
|
}
|
|
|
|
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 gain = dragGain(event)
|
|
const travel = props.dragRange > 0 ? props.dragRange : span.value
|
|
const onMove = (move: PointerEvent) => {
|
|
const delta = startY - move.clientY
|
|
model.value = clamp(startValue + (delta / height) * travel * gain)
|
|
}
|
|
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>
|