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,42 @@
|
||||
<template>
|
||||
<div>
|
||||
<p v-if="heading" class="mb-2 text-xs uppercase tracking-wider text-zinc-500">{{ heading }}</p>
|
||||
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="rounded-xl border px-3 py-2.5 text-left text-sm transition"
|
||||
:class="model === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-200 hover:border-white/20'"
|
||||
@click="model = option.id"
|
||||
>
|
||||
<span class="mb-1.5 flex h-8 items-center" aria-hidden="true">
|
||||
<svg v-if="option.id === 'auto'" class="h-7 w-10" viewBox="0 0 40 28" fill="none">
|
||||
<rect x="11" y="8" width="18" height="12" rx="1.5" stroke="currentColor" stroke-width="1.75" stroke-dasharray="2.5 2" />
|
||||
<path d="M5 9V4h5M35 9V4h-5M5 19v5h5M35 19v5h-5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<svg v-else-if="option.id === '16:9'" class="h-7 w-10" viewBox="0 0 40 28" fill="none">
|
||||
<rect x="2" y="8" width="36" height="13" rx="2.5" stroke="currentColor" stroke-width="1.75" />
|
||||
</svg>
|
||||
<svg v-else-if="option.id === '9:16'" class="h-7 w-10" viewBox="0 0 40 28" fill="none">
|
||||
<rect x="14" y="2" width="12" height="24" rx="2.5" stroke="currentColor" stroke-width="1.75" />
|
||||
</svg>
|
||||
<svg v-else class="h-7 w-10" viewBox="0 0 40 28" fill="none">
|
||||
<rect x="9" y="3" width="22" height="22" rx="2.5" stroke="currentColor" stroke-width="1.75" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="block font-semibold leading-tight">{{ option.label }}</span>
|
||||
<span class="mt-0.5 block text-[11px] leading-snug text-zinc-400">{{ option.hint }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
heading?: string
|
||||
options: { id: string; label: string; hint: string }[]
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
</script>
|
||||
@@ -17,17 +17,16 @@
|
||||
>
|
||||
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-zinc-500">s</span>
|
||||
</div>
|
||||
<input
|
||||
class="aigen-slider mt-3 w-full"
|
||||
type="range"
|
||||
:min="SLIDER_MIN"
|
||||
:max="SLIDER_MAX"
|
||||
:step="0.1"
|
||||
:value="slider"
|
||||
:style="{ background: `linear-gradient(to right, #fbbf24 ${sliderPct}%, #27272a ${sliderPct}%)` }"
|
||||
:aria-label="label || 'Duration in seconds'"
|
||||
@input="onSlider"
|
||||
>
|
||||
<div class="mt-3">
|
||||
<Thumbwheel
|
||||
:model-value="slider"
|
||||
:min="SLIDER_MIN"
|
||||
:max="SLIDER_MAX"
|
||||
:step="0.1"
|
||||
compact
|
||||
@update:model-value="onSliderValue"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="hint" class="mt-1 text-[11px] text-zinc-500">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -51,7 +50,6 @@ const draft = ref(formatDuration(model.value))
|
||||
const scrubbing = ref(false)
|
||||
|
||||
const slider = computed(() => Math.min(SLIDER_MAX, Math.max(SLIDER_MIN, model.value)))
|
||||
const sliderPct = computed(() => ((slider.value - SLIDER_MIN) / (SLIDER_MAX - SLIDER_MIN)) * 100)
|
||||
|
||||
watch(model, (value) => {
|
||||
if (!scrubbing.value) draft.value = formatDuration(value)
|
||||
@@ -72,8 +70,8 @@ function commit() {
|
||||
draft.value = formatDuration(model.value)
|
||||
}
|
||||
|
||||
function onSlider(event: Event) {
|
||||
model.value = clampDuration(Number((event.target as HTMLInputElement).value))
|
||||
function onSliderValue(value: number) {
|
||||
model.value = clampDuration(value)
|
||||
draft.value = formatDuration(model.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="w-[6.5rem] min-w-0">
|
||||
<div v-if="label" class="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-zinc-500">{{ label }}</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
class="lamp-plate relative flex h-[9.75rem] w-full flex-col items-center justify-between overflow-hidden rounded-2xl px-1.5 py-2"
|
||||
:class="model ? 'is-on' : 'is-off'"
|
||||
:aria-checked="model"
|
||||
:aria-label="label || 'Toggle'"
|
||||
@click="model = !model"
|
||||
>
|
||||
<span class="screw tl" aria-hidden="true" />
|
||||
<span class="screw tr" aria-hidden="true" />
|
||||
<span class="screw bl" aria-hidden="true" />
|
||||
<span class="screw br" aria-hidden="true" />
|
||||
|
||||
<span class="flex flex-col items-center gap-1">
|
||||
<span class="led" aria-hidden="true" />
|
||||
<span class="stamp" :class="model ? 'stamp-lit' : ''">ON</span>
|
||||
</span>
|
||||
|
||||
<span class="collar" aria-hidden="true">
|
||||
<span class="collar-ring" />
|
||||
<span class="paddle" :class="model ? 'paddle-on' : 'paddle-off'">
|
||||
<span class="paddle-body" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="stamp" :class="model ? '' : 'stamp-lit'">OFF</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const model = defineModel<boolean>({ required: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lamp-plate {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background:
|
||||
radial-gradient(120% 80% at 50% 0%, rgba(251, 191, 36, 0.08), transparent 55%),
|
||||
linear-gradient(180deg, #2a2a2e 0%, #18181b 42%, #0c0c0e 100%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.12),
|
||||
inset 0 -10px 18px rgba(0, 0, 0, 0.45),
|
||||
0 8px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.lamp-plate.is-on {
|
||||
border-color: rgba(251, 191, 36, 0.45);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(253, 230, 138, 0.18),
|
||||
inset 0 -10px 18px rgba(0, 0, 0, 0.4),
|
||||
0 0 18px rgba(251, 191, 36, 0.18);
|
||||
}
|
||||
.screw {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 35% 30%, #f4f4f5, #a1a1aa 42%, #52525b 70%, #27272a);
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(255, 255, 255, 0.25),
|
||||
inset 0 -1px 1px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.screw::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
background:
|
||||
linear-gradient(#3f3f46, #3f3f46) center / 4px 1px no-repeat,
|
||||
linear-gradient(#3f3f46, #3f3f46) center / 1px 4px no-repeat;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.tl { top: 6px; left: 6px; }
|
||||
.tr { top: 6px; right: 6px; }
|
||||
.bl { bottom: 6px; left: 6px; }
|
||||
.br { bottom: 6px; right: 6px; }
|
||||
.led {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #3f3f46;
|
||||
box-shadow:
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.85),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.is-on .led {
|
||||
background: radial-gradient(circle at 35% 30%, #fef3c7, #fbbf24 55%, #d97706);
|
||||
box-shadow:
|
||||
0 0 10px rgba(251, 191, 36, 1),
|
||||
0 0 18px rgba(251, 191, 36, 0.55),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.stamp {
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.18em;
|
||||
color: rgba(244, 244, 245, 0.28);
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.stamp-lit {
|
||||
color: #fef3c7;
|
||||
text-shadow: 0 0 8px rgba(251, 191, 36, 0.7);
|
||||
}
|
||||
.collar {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 3.35rem;
|
||||
height: 3.35rem;
|
||||
}
|
||||
.collar-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 30% 26%, #fff 0 5%, transparent 16%),
|
||||
repeating-radial-gradient(circle at 50% 50%, #e4e4e7 0 1px, #a1a1aa 2px, #71717a 3px, #3f3f46 4px);
|
||||
box-shadow:
|
||||
0 3px 6px rgba(0, 0, 0, 0.6),
|
||||
inset 0 2px 3px rgba(255, 255, 255, 0.5),
|
||||
inset 0 -4px 6px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.collar-ring::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 50% 40%, #3f3f46, #09090b 72%);
|
||||
box-shadow: inset 0 3px 6px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.paddle {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 1.15rem;
|
||||
height: 2.55rem;
|
||||
transform-origin: 50% 50%;
|
||||
transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
filter: drop-shadow(0 5px 4px rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
.paddle-on {
|
||||
transform: translateY(-0.42rem) rotate(-12deg);
|
||||
}
|
||||
.paddle-off {
|
||||
transform: translateY(0.42rem) rotate(12deg);
|
||||
}
|
||||
.paddle-body {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0.7rem 0.7rem 0.45rem 0.45rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.38) 0%, rgba(255, 255, 255, 0.05) 28%, transparent 45%),
|
||||
linear-gradient(90deg, #0a0a0b 0%, #3f3f46 46%, #111113 100%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.4),
|
||||
inset 0 -3px 4px rgba(0, 0, 0, 0.7);
|
||||
clip-path: polygon(22% 0, 78% 0, 100% 22%, 68% 100%, 32% 100%, 0 22%);
|
||||
}
|
||||
.is-on .paddle-body {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(254, 243, 199, 0.7) 0%, rgba(251, 191, 36, 0.2) 26%, transparent 48%),
|
||||
linear-gradient(90deg, #1c1408 0%, #b45309 48%, #111 100%);
|
||||
box-shadow:
|
||||
0 0 10px rgba(251, 191, 36, 0.45),
|
||||
inset 0 1px 0 rgba(254, 243, 199, 0.55),
|
||||
inset 0 -3px 4px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
</style>
|
||||
+24
-37
@@ -42,39 +42,27 @@
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1.5 grid grid-cols-2 gap-2">
|
||||
<label class="block min-w-0">
|
||||
<span class="mb-0.5 flex justify-between text-[10px] uppercase tracking-wider text-zinc-500">
|
||||
<span>Model</span>
|
||||
<span class="tabular-nums normal-case text-zinc-400">{{ formatLoraStrength(item.strengthModel) }}</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
class="aigen-slider w-full"
|
||||
:min="LORA_STRENGTH_MIN"
|
||||
:max="LORA_STRENGTH_MAX"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
:value="item.strengthModel"
|
||||
:disabled="disabled"
|
||||
@input="setStrength(index, 'strengthModel', $event)"
|
||||
>
|
||||
</label>
|
||||
<label class="block min-w-0" :class="{ 'opacity-50': !clipStrength }">
|
||||
<span class="mb-0.5 flex justify-between text-[10px] uppercase tracking-wider text-zinc-500">
|
||||
<span>CLIP</span>
|
||||
<span class="tabular-nums normal-case text-zinc-400">{{ formatLoraStrength(item.strengthClip) }}</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
class="aigen-slider w-full"
|
||||
:min="LORA_STRENGTH_MIN"
|
||||
:max="LORA_STRENGTH_MAX"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
:value="item.strengthClip"
|
||||
:disabled="disabled || !clipStrength"
|
||||
@input="setStrength(index, 'strengthClip', $event)"
|
||||
>
|
||||
</label>
|
||||
<div class="mt-1.5 flex flex-wrap gap-3">
|
||||
<Thumbwheel
|
||||
:model-value="item.strengthModel"
|
||||
label="Model"
|
||||
:min="LORA_STRENGTH_MIN"
|
||||
:max="LORA_STRENGTH_MAX"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
compact
|
||||
:disabled="disabled"
|
||||
@update:model-value="setStrength(index, 'strengthModel', $event)"
|
||||
/>
|
||||
<Thumbwheel
|
||||
:model-value="item.strengthClip"
|
||||
label="CLIP"
|
||||
:min="LORA_STRENGTH_MIN"
|
||||
:max="LORA_STRENGTH_MAX"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
compact
|
||||
:disabled="disabled || !clipStrength"
|
||||
@update:model-value="setStrength(index, 'strengthClip', $event)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -107,7 +95,6 @@ import {
|
||||
LORA_STRENGTH_MIN,
|
||||
LORA_STRENGTH_STEP,
|
||||
clampLoraStrength,
|
||||
formatLoraStrength,
|
||||
loraDisplayName,
|
||||
loraIdentityKey,
|
||||
makeLoraStackItem,
|
||||
@@ -208,8 +195,8 @@ function onDragEnd() {
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
|
||||
const value = clampLoraStrength((event.target as HTMLInputElement).value)
|
||||
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: value } : item)
|
||||
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
|
||||
const next = clampLoraStrength(value)
|
||||
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: next } : item)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="segment-readout flex h-11 items-center justify-center gap-px px-1.5" aria-hidden="true">
|
||||
<template v-for="(ch, i) in chars" :key="i">
|
||||
<span v-if="ch === '.'" class="dot on" />
|
||||
<span v-else class="digit" :data-n="ch">
|
||||
<i class="seg a" :class="{ on: lit(ch, 'a') }" />
|
||||
<i class="seg b" :class="{ on: lit(ch, 'b') }" />
|
||||
<i class="seg c" :class="{ on: lit(ch, 'c') }" />
|
||||
<i class="seg d" :class="{ on: lit(ch, 'd') }" />
|
||||
<i class="seg e" :class="{ on: lit(ch, 'e') }" />
|
||||
<i class="seg f" :class="{ on: lit(ch, 'f') }" />
|
||||
<i class="seg g" :class="{ on: lit(ch, 'g') }" />
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const MAP: Record<string, string> = {
|
||||
'0': 'abcdef',
|
||||
'1': 'bc',
|
||||
'2': 'abged',
|
||||
'3': 'abcdg',
|
||||
'4': 'fgbc',
|
||||
'5': 'afgcd',
|
||||
'6': 'afedcg',
|
||||
'7': 'abc',
|
||||
'8': 'abcdefg',
|
||||
'9': 'abcdfg',
|
||||
'-': 'g'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
value: string
|
||||
}>()
|
||||
|
||||
const chars = computed(() => {
|
||||
const raw = String(props.value || '0')
|
||||
return raw.split('').filter(ch => ch === '.' || MAP[ch])
|
||||
})
|
||||
|
||||
function lit(ch: string, seg: string) {
|
||||
return (MAP[ch] || '').includes(seg)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.segment-readout {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(9, 9, 11, 0.95), rgba(24, 24, 27, 0.9));
|
||||
box-shadow:
|
||||
inset 0 2px 8px rgba(0, 0, 0, 0.75),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.digit {
|
||||
position: relative;
|
||||
width: 0.72rem;
|
||||
height: 1.15rem;
|
||||
}
|
||||
.seg {
|
||||
position: absolute;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.seg.on {
|
||||
background: #fbbf24;
|
||||
box-shadow: 0 0 6px rgba(251, 191, 36, 0.65);
|
||||
}
|
||||
.a { top: 0; left: 2px; right: 2px; height: 2px; }
|
||||
.d { bottom: 0; left: 2px; right: 2px; height: 2px; }
|
||||
.g { top: calc(50% - 1px); left: 2px; right: 2px; height: 2px; }
|
||||
.f { top: 2px; left: 0; width: 2px; height: calc(50% - 3px); }
|
||||
.b { top: 2px; right: 0; width: 2px; height: calc(50% - 3px); }
|
||||
.e { bottom: 2px; left: 0; width: 2px; height: calc(50% - 3px); }
|
||||
.c { bottom: 2px; right: 0; width: 2px; height: calc(50% - 3px); }
|
||||
.dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
margin: 0 1px 1px;
|
||||
align-self: flex-end;
|
||||
border-radius: 1px;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
}
|
||||
.dot.on {
|
||||
background: #fbbf24;
|
||||
box-shadow: 0 0 6px rgba(251, 191, 36, 0.65);
|
||||
}
|
||||
</style>
|
||||
@@ -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