feat(stage-ui): PropertyColor

This commit is contained in:
Neko Ayaka
2025-08-09 23:04:47 +08:00
parent 389ee0a57c
commit 6309d43cc9
7 changed files with 635 additions and 126 deletions
@@ -1 +1,11 @@
- [freemocap/freemocap: Free Motion Capture for Everyone 💀✨](https://github.com/freemocap/freemocap)
https://quaternius.com/packs/universalanimationlibrary.html
[Mocap Animations for 3D Characters | ActorCore](https://actorcore.reallusion.com/3d-motion?orderBy=Relevance&keyword=talk)
[animation-library/feminine/fbx/expression at master · readyplayerme/animation-library](https://github.com/readyplayerme/animation-library/tree/master/feminine/fbx/expression)
[Office Meeting Animations: 3D Character Animation Pack - 3ds Max MoCap Online](https://mocaponline.com/products/meeting?variant=31814402375751)
[Download 263 Rokoko motion capture assets](https://www.rokoko.com/resources/download-263-rokoko-motion-capture-assets#modal?popup=popup-sign-up)
@@ -1,159 +1,628 @@
<script lang="ts" setup>
import type { DataType, Globals } from 'csstype'
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { convertHsvToRgb, convertRgbToHsv } from 'culori'
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
import { computed, onMounted, ref, watch } from 'vue'
import { useEventListener, useMouseInElement } from '@vueuse/core'
import { convertHsvToRgb } from 'culori'
import { computed, onMounted, ref } from 'vue'
const hue = ref(0)
const modelValue = defineModel<Globals | DataType.Color>({ required: false })
const showPicker = ref(false)
const colorMapRef = ref<HTMLDivElement>()
const { elementX, elementY } = useMouseInElement(colorMapRef)
// Track dragging state
const isDragging = ref(false)
const pickerX = ref(0)
const pickerY = ref(0)
// Calculate color based on picker position
const currentColor = computed(() => {
if (!colorMapRef.value)
return modelValue.value
const rect = colorMapRef.value.getBoundingClientRect()
const x = Math.max(0, Math.min(pickerX.value, rect.width))
const y = Math.max(0, Math.min(pickerY.value, rect.height))
const saturation = (x) / rect.width * 100
const value = (rect.height - y) / rect.height * 100
const rgb = convertHsvToRgb({ h: hue.value, s: saturation, v: value })
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`
})
// Enhanced mousedown to hide cursor globally
function handleMouseDown(event: MouseEvent) {
isDragging.value = true
updatePickerPosition()
event.preventDefault()
interface Props {
modelValue?: string
alpha?: boolean
presets?: string[]
disabled?: boolean
}
function updatePickerPosition() {
const props = withDefaults(defineProps<Props>(), {
modelValue: '#000000',
alpha: true,
presets: () => ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff', '#ffffff', '#000000'],
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
'change': [value: string]
'confirm': []
}>()
// Refs
const colorMapRef = ref<HTMLDivElement>()
const hueSliderRef = ref<HTMLDivElement>()
const alphaSliderRef = ref<HTMLDivElement>()
const popoverRef = ref<HTMLDivElement>()
// State
const isOpen = ref(false)
const isDragging = ref(false)
const dragType = ref<'map' | 'hue' | 'alpha' | null>(null)
const colorSpace = ref<'hex' | 'rgb' | 'hsv'>('hex')
// Color values (internal HSV representation)
const hue = ref(0)
const saturation = ref(100)
const value = ref(50)
const alphaValue = ref(1)
// Color conversion utilities
function hexToRgb(hex: string) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? {
r: Number.parseInt(result[1], 16) / 255,
g: Number.parseInt(result[2], 16) / 255,
b: Number.parseInt(result[3], 16) / 255,
}
: null
}
function parseColor(color: string) {
if (!color)
return { h: 0, s: 0, v: 0, a: 1 }
// Create a temporary element to parse any CSS color
const temp = document.createElement('div')
temp.style.color = color
document.body.appendChild(temp)
const computed = getComputedStyle(temp).color
document.body.removeChild(temp)
// Parse rgb/rgba
const rgbMatch = computed.match(/rgba?\(([^)]+)\)/)
if (rgbMatch) {
const values = rgbMatch[1].split(',').map(v => Number.parseFloat(v.trim()))
const rgb = {
r: values[0] / 255,
g: values[1] / 255,
b: values[2] / 255,
}
const hsv = convertRgbToHsv(rgb)
return {
h: hsv.h || 0,
s: (hsv.s || 0) * 100,
v: (hsv.v || 0) * 100,
a: values[3] !== undefined ? values[3] : 1,
}
}
// Fallback to hex parsing
const rgb = hexToRgb(color)
if (rgb) {
const hsv = convertRgbToHsv(rgb)
return {
h: hsv.h || 0,
s: (hsv.s || 0) * 100,
v: (hsv.v || 0) * 100,
a: 1,
}
}
return { h: 0, s: 0, v: 0, a: 1 }
}
// Computed values
const currentColorHsv = computed(() => ({
h: hue.value,
s: saturation.value / 100,
v: value.value / 100,
}))
const currentColorRgb = computed(() => {
const rgb = convertHsvToRgb(currentColorHsv.value)
return {
r: Math.round(rgb.r * 255),
g: Math.round(rgb.g * 255),
b: Math.round(rgb.b * 255),
}
})
const currentColorHex = computed(() => {
const { r, g, b } = currentColorRgb.value
const hex = ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
return `#${hex}`
})
const currentColor = computed(() => {
if (props.alpha && alphaValue.value < 1) {
const { r, g, b } = currentColorRgb.value
return `rgba(${r}, ${g}, ${b}, ${alphaValue.value})`
}
return currentColorHex.value
})
// Picker positions
const pickerPosition = computed(() => ({
left: `${(saturation.value / 100) * 100}%`,
top: `${100 - (value.value / 100) * 100}%`,
}))
const huePosition = computed(() => ({
left: `${(hue.value / 360) * 100}%`,
}))
const alphaPosition = computed(() => ({
left: `${alphaValue.value * 100}%`,
}))
// Color map background
const colorMapBackground = computed(() => ({
background: `linear-gradient(to right, white, hsl(${hue.value}, 100%, 50%))`,
}))
// Alpha slider background
const alphaSliderBackground = computed(() => {
const { r, g, b } = currentColorRgb.value
return {
background: `linear-gradient(to right, transparent, rgb(${r}, ${g}, ${b}))`,
}
})
// Interaction handlers
function updateColorFromMap(x: number, y: number) {
if (!colorMapRef.value)
return
const rect = colorMapRef.value.getBoundingClientRect()
const newSaturation = Math.max(0, Math.min(100, (x / rect.width) * 100))
const newValue = Math.max(0, Math.min(100, ((rect.height - y) / rect.height) * 100))
pickerX.value = Math.max(0, Math.min(elementX.value, rect.width))
pickerY.value = Math.max(0, Math.min(elementY.value, rect.height))
saturation.value = newSaturation
value.value = newValue
}
function handleMouseMove() {
if (isDragging.value) {
updatePickerPosition()
}
function updateHue(x: number) {
if (!hueSliderRef.value)
return
const rect = hueSliderRef.value.getBoundingClientRect()
const newHue = Math.max(0, Math.min(360, (x / rect.width) * 360))
hue.value = newHue
}
function handleGlobalMouseMove(event: MouseEvent) {
if (isDragging.value) {
if (colorMapRef.value) {
const rect = colorMapRef.value.getBoundingClientRect()
const globalX = event.clientX - rect.left
const globalY = event.clientY - rect.top
function updateAlpha(x: number) {
if (!alphaSliderRef.value)
return
pickerX.value = Math.max(0, Math.min(globalX, rect.width))
pickerY.value = Math.max(0, Math.min(globalY, rect.height))
const rect = alphaSliderRef.value.getBoundingClientRect()
const newAlpha = Math.max(0, Math.min(1, x / rect.width))
alphaValue.value = newAlpha
}
modelValue.value = currentColor.value as Globals | DataType.Color
// Event handlers
function handleColorMapStart(event: MouseEvent | TouchEvent) {
if (props.disabled)
return
isDragging.value = true
dragType.value = 'map'
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY
const rect = colorMapRef.value!.getBoundingClientRect()
updateColorFromMap(clientX - rect.left, clientY - rect.top)
event.preventDefault()
}
function handleHueStart(event: MouseEvent | TouchEvent) {
if (props.disabled)
return
isDragging.value = true
dragType.value = 'hue'
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
const rect = hueSliderRef.value!.getBoundingClientRect()
updateHue(clientX - rect.left)
event.preventDefault()
}
function handleAlphaStart(event: MouseEvent | TouchEvent) {
if (props.disabled)
return
isDragging.value = true
dragType.value = 'alpha'
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
const rect = alphaSliderRef.value!.getBoundingClientRect()
updateAlpha(clientX - rect.left)
event.preventDefault()
}
// Global event handlers
function handleGlobalMove(event: MouseEvent | TouchEvent) {
if (!isDragging.value)
return
event.preventDefault()
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY
switch (dragType.value) {
case 'map': {
const rect = colorMapRef.value!.getBoundingClientRect()
updateColorFromMap(clientX - rect.left, clientY - rect.top)
break
}
case 'hue': {
const rect = hueSliderRef.value!.getBoundingClientRect()
updateHue(clientX - rect.left)
break
}
case 'alpha': {
const rect = alphaSliderRef.value!.getBoundingClientRect()
updateAlpha(clientX - rect.left)
break
}
}
}
function handleGlobalMouseUp() {
function handleGlobalEnd() {
if (isDragging.value) {
handleDragEnd()
}
}
function handleDragEnd() {
if (isDragging.value) {
modelValue.value = currentColor.value as Globals | DataType.Color
isDragging.value = false
dragType.value = null
document.body.style.cursor = ''
// Emit the final color
emit('update:modelValue', currentColor.value)
emit('change', currentColor.value)
emit('confirm')
}
}
onMounted(() => {
useEventListener('mousemove', handleGlobalMouseMove)
useEventListener('mouseup', handleGlobalMouseUp)
// Watch for external color changes
watch(() => props.modelValue, (newValue) => {
if (newValue && !isDragging.value) {
const parsed = parseColor(newValue)
hue.value = parsed.h
saturation.value = parsed.s
value.value = parsed.v
alphaValue.value = parsed.a
}
}, { immediate: true })
// Handle cursor during drag
watch(isDragging, (dragging) => {
if (dragging) {
document.body.style.cursor = 'none'
}
else {
document.body.style.cursor = ''
}
})
// Setup global event listeners
onMounted(() => {
useEventListener('mousemove', handleGlobalMove, { passive: false })
useEventListener('mouseup', handleGlobalEnd)
useEventListener('touchmove', handleGlobalMove, { passive: false })
useEventListener('touchend', handleGlobalEnd)
// Close on outside click
useEventListener('click', (event) => {
if (isOpen.value && popoverRef.value && !popoverRef.value.contains(event.target as Node)) {
isOpen.value = false
}
})
})
// Input handlers for different color spaces
function handleHexInput(hex: string) {
const parsed = parseColor(hex)
hue.value = parsed.h
saturation.value = parsed.s
value.value = parsed.v
alphaValue.value = parsed.a
emit('update:modelValue', hex)
}
function handleRgbInput(channel: 'r' | 'g' | 'b', val: number) {
const rgb = { ...currentColorRgb.value }
rgb[channel] = Math.max(0, Math.min(255, val))
const hsv = convertRgbToHsv({
r: rgb.r / 255,
g: rgb.g / 255,
b: rgb.b / 255,
})
hue.value = hsv.h || 0
saturation.value = (hsv.s || 0) * 100
value.value = (hsv.v || 0) * 100
emit('update:modelValue', currentColor.value)
}
function handleHsvInput(channel: 'h' | 's' | 'v', val: number) {
switch (channel) {
case 'h':
hue.value = Math.max(0, Math.min(360, val))
break
case 's':
saturation.value = Math.max(0, Math.min(100, val))
break
case 'v':
value.value = Math.max(0, Math.min(100, val))
break
}
emit('update:modelValue', currentColor.value)
}
function handleAlphaInput(val: number) {
alphaValue.value = Math.max(0, Math.min(1, val / 100))
emit('update:modelValue', currentColor.value)
}
function selectPreset(preset: string) {
const parsed = parseColor(preset)
hue.value = parsed.h
saturation.value = parsed.s
value.value = parsed.v
alphaValue.value = parsed.a
emit('update:modelValue', preset)
emit('change', preset)
}
</script>
<template>
<div h-full w-full>
<div relative w-full class="data-pane-teleport-wrapper">
<label>
<div min-h-5 :style="{ backgroundColor: modelValue }" />
<input v-model="showPicker" type="checkbox" invisible absolute appearance-none>
</label>
<div v-if="showPicker" translate-y="100%" absolute bottom--3 left-0 z-10 h-70 w-90 overflow-hidden rounded-xl>
<div bg="white dark:neutral-900" h-full w-full p-2>
<div
ref="colorMapRef"
class="color-map"
:style="{ cursor: isDragging ? 'none' : 'crosshair' }"
rounded-md
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
>
<PopoverRoot>
<PopoverTrigger>
<div min-h-5 rounded-md :style="{ backgroundColor: modelValue }" />
</PopoverTrigger>
<PopoverPortal>
<PopoverContent>
<div class="space-y-2" bg="white/90 dark:bg-neutral-900/90" rounded-xl p-2>
<!-- Color Map -->
<div class="relative h-48 w-full select-none overflow-hidden rounded-lg">
<div
class="color-picker"
absolute
:style="{
top: `${pickerY - 12}px`,
left: `${pickerX - 12}px`,
transform: isDragging ? 'scale(1.2)' : '',
}"
ref="colorMapRef"
class="relative h-full w-full cursor-crosshair"
:style="[
colorMapBackground,
{ cursor: isDragging && dragType === 'map' ? 'none' : 'crosshair' },
]"
@mousedown="handleColorMapStart"
@touchstart="handleColorMapStart"
>
<!-- Brightness overlay -->
<div
class="absolute inset-0"
style="background: linear-gradient(to bottom, transparent, black);"
/>
<!-- Color picker circle -->
<div
class="pointer-events-none absolute h-4 w-4 border-2 border-white rounded-full shadow-lg transition-transform"
:style="[
pickerPosition,
{
transform: `translate(-50%, -50%) ${isDragging && dragType === 'map' ? 'scale(1.2)' : 'scale(1)'}`,
backgroundColor: currentColor,
},
]"
/>
</div>
</div>
<!-- Hue Slider -->
<div class="relative h-6 w-full select-none overflow-hidden rounded-lg">
<div
ref="hueSliderRef"
class="hue-slider h-full w-full cursor-pointer"
style="background: linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000);"
:style="{ cursor: isDragging && dragType === 'hue' ? 'none' : 'pointer' }"
@mousedown="handleHueStart"
@touchstart="handleHueStart"
>
<div
class="pointer-events-none absolute top-0 h-14 w-1 cursor-pointer cursor-col-resize appearance-none bg-white shadow-lg transition-colors,transform,width,height duration-200 hover:h-13 hover:w-2 hover:bg-neutral-800"
:style="[
huePosition,
{
transform: `translateX(-50%) ${isDragging && dragType === 'hue' ? 'scaleY(1.2)' : 'scaleY(1)'}`,
},
]"
/>
</div>
</div>
<!-- Alpha Slider -->
<div v-if="alpha" class="relative h-6 w-full select-none overflow-hidden rounded-lg">
<!-- Checkerboard background -->
<div
class="alpha-slider absolute inset-0 opacity-50"
style="
background-image:
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
"
/>
<div
ref="alphaSliderRef"
class="relative h-full w-full cursor-pointer"
:style="[
alphaSliderBackground,
{ cursor: isDragging && dragType === 'alpha' ? 'none' : 'pointer' },
]"
@mousedown="handleAlphaStart"
@touchstart="handleAlphaStart"
>
<div
class="pointer-events-none absolute top-0 h-14 w-1 cursor-pointer cursor-col-resize appearance-none bg-white shadow-lg transition-colors,transform,width,height duration-200 hover:h-13 hover:w-2 hover:bg-neutral-800"
:style="[
alphaPosition,
{
transform: `translateX(-50%) ${isDragging && dragType === 'alpha' ? 'scaleY(1.2)' : 'scaleY(1)'}`,
},
]"
/>
</div>
</div>
<!-- Color Inputs -->
<div class="space-y-3">
<div class="flex gap-2">
<select
v-model="colorSpace"
class="flex-shrink-0 border rounded-lg px-2 py-1 text-sm"
>
<option value="hex">
HEX
</option>
<option value="rgb">
RGB
</option>
<option value="hsv">
HSV
</option>
</select>
</div>
<!-- HEX Input -->
<div v-if="colorSpace === 'hex'" class="flex gap-2">
<input
:value="currentColorHex"
class="flex-1 border rounded-lg px-2 py-1 text-sm font-mono"
placeholder="#000000"
@input="handleHexInput(($event?.target as HTMLInputElement).value)"
>
<input
v-if="alpha"
type="number"
:value="Math.round(alphaValue * 100)"
min="0"
max="100"
class="w-16 border rounded px-2 py-1 text-sm"
placeholder="A%"
@input="handleAlphaInput(Number(($event?.target as HTMLInputElement).value))"
>
</div>
<!-- RGB Inputs -->
<div v-else-if="colorSpace === 'rgb'" class="grid gap-2" :class="alpha ? 'grid-cols-4' : 'grid-cols-3'">
<input
type="number"
:value="currentColorRgb.r"
min="0"
max="255"
class="border rounded-lg px-2 py-1 text-sm"
placeholder="R"
@input="handleRgbInput('r', Number(($event?.target as HTMLInputElement).value))"
>
<input
type="number"
:value="currentColorRgb.g"
min="0"
max="255"
class="border rounded-lg px-2 py-1 text-sm"
placeholder="G"
@input="handleRgbInput('g', Number(($event?.target as HTMLInputElement).value))"
>
<input
type="number"
:value="currentColorRgb.b"
min="0"
max="255"
class="border rounded-lg px-2 py-1 text-sm"
placeholder="B"
@input="handleRgbInput('b', Number(($event?.target as HTMLInputElement).value))"
>
<input
v-if="alpha"
type="number"
:value="Math.round(alphaValue * 100)"
min="0"
max="100"
class="border rounded-lg px-2 py-1 text-sm"
placeholder="A%"
@input="handleAlphaInput(Number(($event?.target as HTMLInputElement).value))"
>
</div>
<!-- HSV Inputs -->
<div v-else-if="colorSpace === 'hsv'" class="grid gap-2" :class="alpha ? 'grid-cols-4' : 'grid-cols-3'">
<input
type="number"
:value="Math.round(hue)"
min="0"
max="360"
class="border rounded-lg px-2 py-1 text-sm"
placeholder="H°"
@input="handleHsvInput('h', Number(($event?.target as HTMLInputElement).value))"
>
<input
type="number"
:value="Math.round(saturation)"
min="0"
max="100"
class="border rounded-lg px-2 py-1 text-sm"
placeholder="S%"
@input="handleHsvInput('s', Number(($event?.target as HTMLInputElement).value))"
>
<input
type="number"
:value="Math.round(value)"
min="0"
max="100"
class="border rounded-lg px-2 py-1 text-sm"
placeholder="V%"
@input="handleHsvInput('v', Number(($event?.target as HTMLInputElement).value))"
>
<input
v-if="alpha"
type="number"
:value="Math.round(alphaValue * 100)"
min="0"
max="100"
class="border rounded-lg px-2 py-1 text-sm"
placeholder="A%"
@input="handleAlphaInput(Number(($event?.target as HTMLInputElement).value))"
>
</div>
</div>
<!-- Color Presets -->
<div v-if="presets.length > 0" class="grid grid-cols-8 gap-2">
<button
v-for="preset in presets"
:key="preset"
class="h-8 w-8 border-2 border-neutral-200 rounded-lg transition-colors hover:border-neutral-700"
:style="{ backgroundColor: preset }"
@click="selectPreset(preset)"
/>
</div>
</div>
</div>
</div>
</div>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<style scoped>
.color-map::before {
content: "";
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
background-image: linear-gradient(to bottom, transparent, black);
}
<style>
.hue-slider,
.alpha-slider {
&::-webkit-slider-thumb {
--at-apply: w-1 h-12 hover:w-2 hover:h-13 appearance-none rounded-md bg-neutral-600 cursor-pointer shadow-lg border-2 border-neutral-500
hover: bg-neutral-800 transition-colors,transform,width,height duration-200 cursor-col-resize;
}
.color-map {
width: 100%;
height: 100%;
background-image: linear-gradient(to left, rgb(255, 0, 0), white);
display: inline-block;
position: relative;
overflow: hidden;
cursor: crosshair;
user-select: none;
}
.dark &::-webkit-slider-thumb {
--at-apply: w-1 h-12 hover:w-2 hover:h-13 appearance-none rounded-md bg-neutral-100 cursor-pointer shadow-md border-2 border-white
hover: bg-neutral-300 transition-colors,transform,width,height duration-200 cursor-col-resize;
}
.color-picker {
width: 24px;
height: 24px;
display: inline-block;
position: absolute;
border-radius: 50%;
border: 2px solid white;
pointer-events: none;
transition: transform 0.15s ease-in-out;
&::-moz-range-thumb {
--at-apply: w-1 h-12 hover:w-2 hover:h-13 appearance-none rounded-md bg-neutral-600 cursor-pointer shadow-lg border-2 border-neutral-500
hover: bg-neutral-800 transition-colors,transform,width,height duration-200 cursor-col-resize;
}
.dark &::-moz-range-thumb {
--at-apply: w-1 h-12 hover:w-2 hover:h-13 appearance-none rounded-md bg-neutral-100 cursor-pointer shadow-md border-2 border-white
hover: bg-neutral-300 transition-colors,transform,width,height duration-200 cursor-col-resize;
}
}
</style>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DataType, Globals } from 'csstype'
import ColorPicker from './ColorPicker.vue'
const props = withDefaults(defineProps<{
label?: string
}>(), {
label: 'Color Picker',
})
const modelValue = defineModel<Globals | DataType.Color>({ required: false })
</script>
<template>
<div>
<slot name="label">
<span text-nowrap text-xs>{{ props.label }}</span>
</slot>
</div>
<div />
<ColorPicker v-model="modelValue" grid-col-span-2 />
</template>
@@ -4,7 +4,8 @@ import type { Ref } from 'vue'
import { ref } from 'vue'
import ColorPicker from './ColorPicker.vue'
import PropertyColor from './PropertyColor.vue'
import PropertyNumber from './PropertyNumber.vue'
type DataPaneSchemaTypes
= | DataPaneSchemaNumber
@@ -147,13 +148,19 @@ const { data, schema } = useDataPane({
</template>
<Variant id="default-min" title="Default">
<div h-100 flex flex-col>
<div grid grid-cols-5 gap-2>
<template v-for="(fieldSchema, fieldName) in schema" :key="fieldName">
<template v-if="fieldSchema.type === 'number'">
<input v-model="data[fieldName]" type="number" class="data-pane">
<PropertyNumber
v-model="data[fieldName] as number"
:min="fieldSchema.min"
:max="fieldSchema.max"
:step="fieldSchema.step"
:label="fieldSchema.label"
/>
</template>
<template v-if="fieldSchema.type === 'color'">
<ColorPicker v-model="data[fieldName] as Globals | DataType.Color" />
<PropertyColor v-model="data[fieldName] as Globals | DataType.Color" />
</template>
</template>
</div>