Overhaul Studio controls and add image batch from one still.
Video and Image pickers, thumbwheels, aspect icons, and a Scale to MP paddle; extra prompts default to batch so each run starts from the original image. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
<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 border-white/10 bg-zinc-950">
|
||||
<div class="border-b border-white/5">
|
||||
<SegmentDigits :value="display" />
|
||||
</div>
|
||||
<div
|
||||
ref="root"
|
||||
class="thumbwheel relative w-full select-none"
|
||||
:class="[
|
||||
compact ? 'h-28' : 'h-36',
|
||||
disabled ? 'pointer-events-none' : 'cursor-ns-resize'
|
||||
]"
|
||||
role="slider"
|
||||
:aria-label="ariaLabel || label || 'Value'"
|
||||
:aria-valuemin="min"
|
||||
:aria-valuemax="max"
|
||||
:aria-valuenow="model"
|
||||
:aria-disabled="disabled"
|
||||
tabindex="0"
|
||||
@pointerdown="onPointerDown"
|
||||
@wheel.prevent="onWheel"
|
||||
@keydown="onKey"
|
||||
>
|
||||
<div
|
||||
class="thumbwheel-drum pointer-events-none absolute inset-y-1 left-1 right-8 overflow-hidden rounded-xl"
|
||||
: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
|
||||
disabled?: boolean
|
||||
compact?: boolean
|
||||
digits?: number
|
||||
}>(), {
|
||||
step: 0.1,
|
||||
fineStep: 0.1,
|
||||
coarseStep: 1,
|
||||
label: '',
|
||||
ariaLabel: '',
|
||||
disabled: false,
|
||||
compact: false,
|
||||
digits: 1
|
||||
})
|
||||
|
||||
const model = defineModel<number>({ required: true })
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
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 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 (props.disabled) return
|
||||
const step = event.shiftKey ? props.coarseStep : props.fineStep
|
||||
nudge(event.deltaY < 0 ? step : -step)
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (props.disabled) 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 (props.disabled || 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)
|
||||
}
|
||||
</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>
|
||||
Reference in New Issue
Block a user