openpencil/src/components/ScrubInput.vue

153 lines
4.2 KiB
Vue

<script setup lang="ts">
import { ref, computed } from 'vue'
import { useEventListener } from '@vueuse/core'
const props = withDefaults(
defineProps<{
modelValue: number | symbol
min?: number
max?: number
step?: number
icon?: string
label?: string
suffix?: string
sensitivity?: number
placeholder?: string
}>(),
{
min: -Infinity,
max: Infinity,
step: 1,
sensitivity: 1,
placeholder: 'Mixed'
}
)
const isMixed = computed(() => typeof props.modelValue === 'symbol')
const emit = defineEmits<{
'update:modelValue': [value: number]
commit: [value: number, previous: number]
}>()
const editing = ref(false)
const inputRef = ref<HTMLInputElement | null>(null)
const scrubbing = ref(false)
let stopMove: (() => void) | undefined
let stopUp: (() => void) | undefined
const numericValue = computed(() => (isMixed.value ? 0 : (props.modelValue as number)))
const displayValue = computed(() => (isMixed.value ? '' : Math.round(numericValue.value)))
function startScrub(e: PointerEvent) {
e.preventDefault()
const startX = e.clientX
let lastX = startX
let accumulated = numericValue.value
const valueBeforeScrub = numericValue.value
let hasMoved = false
stopMove = useEventListener(document, 'pointermove', (ev: PointerEvent) => {
const dx = ev.clientX - lastX
lastX = ev.clientX
if (!hasMoved && Math.abs(ev.clientX - startX) > 2) {
hasMoved = true
scrubbing.value = true
document.body.style.cursor = 'ew-resize'
}
if (hasMoved) {
accumulated += dx * props.step * props.sensitivity
const clamped = Math.round(Math.min(props.max, Math.max(props.min, accumulated)))
if (clamped !== props.modelValue) {
emit('update:modelValue', clamped)
}
}
})
stopUp = useEventListener(document, 'pointerup', () => {
scrubbing.value = false
document.body.style.cursor = ''
stopMove?.()
stopUp?.()
if (hasMoved) {
if (props.modelValue !== valueBeforeScrub) {
emit('commit', props.modelValue, valueBeforeScrub)
}
} else {
startEdit()
}
})
}
function startEdit() {
editing.value = true
requestAnimationFrame(() => {
inputRef.value?.select()
})
}
function commitEdit(e: Event) {
const val = +(e.target as HTMLInputElement).value
const previous = numericValue.value
if (!Number.isNaN(val)) {
const clamped = Math.min(props.max, Math.max(props.min, val))
emit('update:modelValue', clamped)
if (clamped !== previous) {
emit('commit', clamped, previous)
}
}
editing.value = false
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
commitEdit(e)
} else if (e.key === 'Escape') {
editing.value = false
}
}
</script>
<template>
<div
data-test-id="scrub-input"
class="flex min-w-0 flex-1 items-center rounded border border-border bg-input h-[26px] focus-within:border-accent"
:style="{ cursor: editing ? 'auto' : 'ew-resize' }"
@pointerdown="!editing && startScrub($event)"
>
<span
class="flex shrink-0 select-none items-center justify-center self-stretch px-[5px] text-muted [&>*]:pointer-events-none"
>
<slot name="icon">
<span v-if="icon" class="text-[11px] leading-none">{{ icon }}</span>
</slot>
<span v-if="label" class="text-[11px] leading-none">{{ label }}</span>
</span>
<input
v-if="editing"
ref="inputRef"
type="number"
data-test-id="scrub-input-field"
class="min-w-0 flex-1 cursor-text border-none bg-transparent pr-1.5 font-[inherit] text-xs text-surface outline-none"
:value="isMixed ? '' : displayValue"
:placeholder="placeholder"
:min="min === -Infinity ? undefined : min"
:max="max === Infinity ? undefined : max"
:step="step"
@blur="commitEdit"
@keydown="onKeydown"
/>
<span
v-else
class="flex flex-1 select-none items-center truncate pr-1.5 text-xs overflow-hidden"
>
<span v-if="isMixed" class="flex-1 text-muted">{{ placeholder }}</span>
<template v-else>
<span class="flex-1 text-surface">{{ displayValue }}</span>
<span v-if="suffix" class="shrink-0 text-muted">{{ suffix }}</span>
</template>
</span>
</div>
</template>