|
|
@@ -0,0 +1,168 @@
|
|
|
+<template>
|
|
|
+ <div
|
|
|
+ class="spinbox-root relative flex h-full w-full items-center box-border"
|
|
|
+ :style="{ gap: `${buttonGap}px` }"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ class="relative box-border flex shrink-0 items-center justify-center overflow-hidden"
|
|
|
+ :style="buttonStyleMap.left"
|
|
|
+ >
|
|
|
+ <ImageBg
|
|
|
+ v-if="styleMap?.leftButtonStyle?.imageSrc"
|
|
|
+ :src="styleMap.leftButtonStyle.imageSrc"
|
|
|
+ :imageStyle="styleMap.leftButtonStyle.imageStyle"
|
|
|
+ />
|
|
|
+ <span class="relative z-1 whitespace-pre!">
|
|
|
+ <LuMinus size="16px" />
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="relative min-w-0 flex-1 overflow-hidden box-border" :style="styleMap?.mainStyle">
|
|
|
+ <ImageBg
|
|
|
+ v-if="styleMap?.mainStyle?.imageSrc"
|
|
|
+ :src="styleMap.mainStyle.imageSrc"
|
|
|
+ :imageStyle="styleMap.mainStyle.imageStyle"
|
|
|
+ />
|
|
|
+ <div
|
|
|
+ class="relative z-1 flex h-full w-full items-center"
|
|
|
+ :style="{ justifyContent: textJustify }"
|
|
|
+ >
|
|
|
+ <div class="inline-flex max-w-full items-center whitespace-pre!">
|
|
|
+ <span
|
|
|
+ v-for="(char, index) in displayChars"
|
|
|
+ :key="`${index}-${char}`"
|
|
|
+ class="inline-block"
|
|
|
+ :style="index === cursorIndex ? styleMap?.cursorStyle : undefined"
|
|
|
+ >
|
|
|
+ {{ char }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ class="relative box-border flex shrink-0 items-center justify-center overflow-hidden"
|
|
|
+ :style="buttonStyleMap.right"
|
|
|
+ >
|
|
|
+ <ImageBg
|
|
|
+ v-if="styleMap?.rightButtonStyle?.imageSrc"
|
|
|
+ :src="styleMap.rightButtonStyle.imageSrc"
|
|
|
+ :imageStyle="styleMap.rightButtonStyle.imageStyle"
|
|
|
+ />
|
|
|
+ <span class="relative z-1 whitespace-pre!">
|
|
|
+ <LuPlus size="16px" />
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed } from 'vue'
|
|
|
+import { useWidgetStyle } from '../hooks/useWidgetStyle'
|
|
|
+import ImageBg from '../ImageBg.vue'
|
|
|
+import { LuPlus, LuMinus } from 'vue-icons-plus/lu'
|
|
|
+
|
|
|
+const DEFAULT_HEIGHT = 40
|
|
|
+
|
|
|
+const props = defineProps<{
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ styles: any[]
|
|
|
+ state?: string
|
|
|
+ part?: string
|
|
|
+ rangeMin: number
|
|
|
+ rangeMax: number
|
|
|
+ step: number
|
|
|
+ integerDigits: number
|
|
|
+ decimalPlaces: number
|
|
|
+ value: number
|
|
|
+ rollOver: boolean
|
|
|
+}>()
|
|
|
+
|
|
|
+const styleMap = useWidgetStyle({
|
|
|
+ widget: 'lv_spinbox',
|
|
|
+ props
|
|
|
+})
|
|
|
+
|
|
|
+const buttonGap = computed(() => 6)
|
|
|
+
|
|
|
+const buttonSize = computed(() => {
|
|
|
+ return Math.max(24, props.height || DEFAULT_HEIGHT)
|
|
|
+})
|
|
|
+
|
|
|
+const buttonStyleMap = computed(() => {
|
|
|
+ const size = `${buttonSize.value}px`
|
|
|
+ return {
|
|
|
+ left: {
|
|
|
+ ...styleMap.value?.leftButtonStyle,
|
|
|
+ width: size,
|
|
|
+ height: size
|
|
|
+ },
|
|
|
+ right: {
|
|
|
+ ...styleMap.value?.rightButtonStyle,
|
|
|
+ width: size,
|
|
|
+ height: size
|
|
|
+ }
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const normalizedRange = computed(() => {
|
|
|
+ return {
|
|
|
+ min: Math.min(props.rangeMin, props.rangeMax),
|
|
|
+ max: Math.max(props.rangeMin, props.rangeMax)
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const normalizedValue = computed(() => {
|
|
|
+ const { min, max } = normalizedRange.value
|
|
|
+ const current = props.value ?? 0
|
|
|
+
|
|
|
+ if (props.rollOver) {
|
|
|
+ if (current < min) return max
|
|
|
+ if (current > max) return min
|
|
|
+ return current
|
|
|
+ }
|
|
|
+
|
|
|
+ return Math.max(min, Math.min(max, current))
|
|
|
+})
|
|
|
+
|
|
|
+const formattedValue = computed(() => {
|
|
|
+ const decimals = Math.max(0, Math.min(10, props.decimalPlaces ?? 0))
|
|
|
+ const integerDigits = Math.max(1, Math.min(5, props.integerDigits ?? 1))
|
|
|
+ const value = normalizedValue.value
|
|
|
+ const sign = value >= 0 ? '+' : '-'
|
|
|
+ const absolute = Math.abs(value)
|
|
|
+ const [integerPartRaw, fractionPart = ''] = absolute.toFixed(decimals).split('.')
|
|
|
+ const integerPart = integerPartRaw.padStart(integerDigits, '0')
|
|
|
+
|
|
|
+ return decimals > 0 ? `${sign}${integerPart}.${fractionPart}` : `${sign}${integerPart}`
|
|
|
+})
|
|
|
+
|
|
|
+const displayChars = computed(() => formattedValue.value.split(''))
|
|
|
+
|
|
|
+const cursorIndex = computed(() => {
|
|
|
+ for (let index = displayChars.value.length - 1; index >= 0; index -= 1) {
|
|
|
+ if (/\d/.test(displayChars.value[index])) {
|
|
|
+ return index
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return 0
|
|
|
+})
|
|
|
+
|
|
|
+const textJustify = computed(() => {
|
|
|
+ switch (styleMap.value?.mainStyle?.textAlign) {
|
|
|
+ case 'center':
|
|
|
+ return 'center'
|
|
|
+ case 'right':
|
|
|
+ return 'flex-end'
|
|
|
+ default:
|
|
|
+ return 'flex-start'
|
|
|
+ }
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.spinbox-root {
|
|
|
+ user-select: none;
|
|
|
+}
|
|
|
+</style>
|