feat(stage-*): allow setting character offset in landscape (#1696)

This commit is contained in:
DrHuangMHT
2026-05-06 17:04:18 +08:00
committed by GitHub
parent 33cd61111f
commit 85627d34ad
26 changed files with 456 additions and 339 deletions
-5
View File
@@ -15,7 +15,6 @@ import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
@@ -33,7 +32,6 @@ function handleSettingsOpen(open: boolean) {
}
const positionCursor = useMouse()
const { scale, position, positionInPercentageString } = storeToRefs(useLive2d())
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
@@ -199,9 +197,6 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="`${isMobile ? position.x : position.x - 10}%`"
:y-offset="positionInPercentageString.y"
:scale="scale"
/>
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
<MobileInteractiveArea v-if="isMobile" @settings-open="handleSettingsOpen">
@@ -25,7 +25,6 @@ import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
import { useCanvasPixelIsTransparentAtPoint } from '@proj-airi/stage-ui/composables/canvas-alpha'
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
@@ -113,8 +112,6 @@ const isAroundWindowBorderFor250Ms = refDebounced(isAroundWindowBorder, 250)
const setIgnoreMouseEvents = useElectronEventaInvoke(electron.window.setIgnoreMouseEvents)
const live2dStore = useLive2d()
const { scale, positionInPercentageString } = storeToRefs(live2dStore)
const { live2dLookAtX, live2dLookAtY } = storeToRefs(useWindowStore())
const { pause, resume } = watch(isTransparent, (transparent) => {
@@ -484,9 +481,6 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
flex-1
:paused="stagePaused"
:focus-at="{ x: live2dLookAtX, y: live2dLookAtY }"
:scale="scale"
:x-offset="positionInPercentageString.x"
:y-offset="positionInPercentageString.y"
/>
<HoloCoupon />
<ControlsIsland
-5
View File
@@ -15,7 +15,6 @@ import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
@@ -31,7 +30,6 @@ function handleSettingsOpen(open: boolean) {
}
const positionCursor = useMouse()
const { scale, position, positionInPercentageString } = storeToRefs(useLive2d())
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
@@ -173,9 +171,6 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="`${isMobile ? position.x : position.x - 10}%`"
:y-offset="positionInPercentageString.y"
:scale="scale"
/>
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
<MobileInteractiveArea v-if="isMobile" @settings-open="handleSettingsOpen" />
@@ -1,42 +1,57 @@
<script lang="ts" setup>
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { supportedControl as threeSupportedControl, useThreeViewControl } from '@proj-airi/stage-ui-three'
import { supportedControl as l2dSupportedCtrl, useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { Button } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const emits = defineEmits<{
(e: 'reset'): void
}>()
const { stageModelRenderer } = storeToRefs(useSettingsStageModel())
const { viewControlsEnabled: l2dViewCtrlEnabled, viewControlMode: l2dCtrlMode, reset: l2dReset } = useL2dViewControl()
const { viewControlsEnabled: threePlainCtrlEnabled, viewControlMode: threeCtrlMode, reset: threeReset } = useThreeViewControl()
const controlEnabled = computed(() => {
if (stageModelRenderer.value === 'live2d')
return { enabled: l2dViewCtrlEnabled, mode: l2dCtrlMode, supported: l2dSupportedCtrl, reset: l2dReset }
if (stageModelRenderer.value === 'vrm')
return { enabled: threePlainCtrlEnabled, mode: threeCtrlMode, supported: threeSupportedControl, reset: threeReset }
return null
})
const { stageModelRenderer, stageViewControlsEnabled } = storeToRefs(useSettings())
const mode = defineModel<'x' | 'y' | 'z' | 'scale'>({ required: true })
function handleViewControlsToggle(targetMode: 'x' | 'y' | 'z' | 'scale') {
if (mode.value === targetMode) {
emits('reset')
function handleViewControlsToggle(targetMode: string) {
if (!controlEnabled.value || !controlEnabled.value.supported.includes(targetMode as any))
return
if (controlEnabled.value.mode.value === targetMode) {
controlEnabled.value.reset(controlEnabled.value.mode.value as any)
return
}
mode.value = targetMode
controlEnabled.value.mode.value = targetMode as any
}
// watch()
</script>
<template>
<div w-full flex flex-1 items-center self-end justify-end gap-2>
<Transition name="fade">
<div v-if="stageViewControlsEnabled" w-full flex justify-between gap-2>
<Button variant="secondary-muted" :toggled="mode === 'x'" w-full @click="handleViewControlsToggle('x')">
<div v-if="controlEnabled?.enabled.value" w-full flex justify-between gap-2>
<Button variant="secondary-muted" :toggled="controlEnabled.mode.value === 'x'" w-full @click="handleViewControlsToggle('x')">
X
</Button>
<Button variant="secondary-muted" :toggled="mode === 'y'" w-full @click="handleViewControlsToggle('y')">
<Button variant="secondary-muted" :toggled="controlEnabled.mode.value === 'y'" w-full @click="handleViewControlsToggle('y')">
Y
</Button>
<Button v-if="stageModelRenderer === 'vrm'" variant="secondary-muted" :toggled="mode === 'z'" w-full @click="handleViewControlsToggle('z')">
<Button v-if="controlEnabled.supported.includes('z' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'z'" w-full @click="handleViewControlsToggle('z')">
Z
</Button>
<Button variant="secondary-muted" :toggled="mode === 'scale'" w-full @click="handleViewControlsToggle('scale')">
<Button v-if="controlEnabled.supported.includes('cameraFOV' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'cameraFOV'" w-full @click="handleViewControlsToggle('cameraFOV')">
FOV
</Button>
<Button v-if="controlEnabled.supported.includes('scale' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'scale'" w-full @click="handleViewControlsToggle('scale')">
Scale
</Button>
<Button v-if="controlEnabled.supported.includes('cameraDistance' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'cameraDistance'" w-full @click="handleViewControlsToggle('cameraDistance')">
Dis
</Button>
</div>
</Transition>
<button
@@ -45,11 +60,11 @@ function handleViewControlsToggle(targetMode: 'x' | 'y' | 'z' | 'scale') {
bg="neutral-50/70 dark:neutral-800/70"
title="View"
text="neutral-500 dark:neutral-400"
@click="stageViewControlsEnabled = !stageViewControlsEnabled"
@click="controlEnabled && (controlEnabled.enabled.value = !controlEnabled.enabled.value)"
>
<Transition name="fade" mode="out-in">
<div v-if="!stageViewControlsEnabled" i-solar:tuning-outline size-5 />
<div v-else i-solar:alt-arrow-right-outline size-5 />
<div v-if="controlEnabled?.enabled.value" i-solar:alt-arrow-right-outline size-5 />
<div v-else i-solar:tuning-outline size-5 />
</Transition>
</button>
</div>
@@ -2,6 +2,7 @@
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
import type { ChatProvider } from '@xsai-ext/providers/utils'
import { useThreeViewControl } from '@proj-airi/stage-ui-three'
import { ChatHistory, HearingConfigDialog } from '@proj-airi/stage-ui/components'
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
@@ -9,20 +10,20 @@ import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { BasicTextarea, useTheme } from '@proj-airi/ui'
import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import ViewControls from '../Layouts/InteractiveArea/Actions/ViewControls.vue'
import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue'
import ActionAbout from './InteractiveArea/Actions/About.vue'
import ActionViewControls from './InteractiveArea/Actions/ViewControls.vue'
import ViewControlInputs from './ViewControls/Inputs.vue'
import { BackgroundDialogPicker } from '../Backgrounds'
@@ -41,9 +42,6 @@ function handleDeleteMessage(index: number) {
messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index)
}
const viewControlsActiveMode = ref<'x' | 'y' | 'z' | 'scale'>('scale')
const viewControlsInputsRef = useTemplateRef<InstanceType<typeof ViewControlInputs>>('viewControlsInputs')
const messageInput = ref('')
const isComposing = ref(false)
const backgroundDialogOpen = ref(false)
@@ -53,7 +51,9 @@ const providersStore = useProvidersStore()
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
useResizeObserver(document.documentElement, () => screenSafeArea.update())
const { themeColorsHueDynamic, stageViewControlsEnabled } = storeToRefs(useSettings())
const { themeColorsHueDynamic } = storeToRefs(useSettings())
const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl()
const { viewControlsEnabled: threeViewCtrlEnabled } = useThreeViewControl()
const settingsAudioDevice = useSettingsAudioDevice()
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(settingsAudioDevice)
const { ingest, onAfterMessageComposed } = chatOrchestrator
@@ -149,7 +149,7 @@ onMounted(() => {
<KeepAlive>
<Transition name="fade">
<ChatHistory
v-if="!stageViewControlsEnabled"
v-if="!threeViewCtrlEnabled && !l2dViewCtrlEnabled"
variant="mobile"
:messages="historyMessages"
:sending="sending"
@@ -165,9 +165,6 @@ onMounted(() => {
</Transition>
</KeepAlive>
<div relative w-full self-end>
<div top="50%" translate-y="[-50%]" fixed z-15 px-3>
<ViewControlInputs ref="viewControlsInputs" :mode="viewControlsActiveMode" />
</div>
<div translate-y="[-100%]" absolute left-0 px-3 pb-3 font-sans>
<div flex="~ col" gap-1>
<slot name="status" />
@@ -223,7 +220,7 @@ onMounted(() => {
>
<div class="i-solar:trash-bin-2-bold-duotone" />
</button>
<ActionViewControls v-model="viewControlsActiveMode" @reset="() => viewControlsInputsRef?.resetOnMode()" />
<ViewControls />
</div>
</div>
<div bg="white dark:neutral-800" max-h-100dvh max-w-100dvw w-full flex gap-1 overflow-auto px-3 pt-2 :style="{ paddingBottom: `${Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 12)}px` }">
@@ -1,215 +0,0 @@
<script setup lang="ts">
import { useModelStore } from '@proj-airi/stage-ui-three'
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { RoundRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const props = defineProps<{
mode: 'x' | 'y' | 'z' | 'scale'
}>()
const { stageModelRenderer, stageViewControlsEnabled } = storeToRefs(useSettings())
const { modelOffset: vrmPosition, modelSize: vrmModelSize, cameraDistance: vrmCameraDistance, sceneMutationLocked: vrmSceneMutationLocked } = storeToRefs(useModelStore())
const { scale: live2dScale, position: live2dPosition } = storeToRefs(useLive2d())
const vrmControlsDisabled = computed(() => {
return stageModelRenderer.value === 'vrm' && vrmSceneMutationLocked.value
})
const viewControlsValueX = computed({
get: () => {
switch (stageModelRenderer.value) {
case 'live2d':
return live2dPosition.value.x
case 'vrm':
return vrmPosition.value.x * 100
default:
return 0
}
},
set: (value) => {
switch (stageModelRenderer.value) {
case 'live2d':
live2dPosition.value.x = value
break
case 'vrm':
if (vrmControlsDisabled.value)
break
vrmPosition.value.x = value / 100
break
default:
break
}
},
})
const viewControlsValueXMin = computed(() => {
return stageModelRenderer.value === 'live2d' ? -1000 : (-vrmModelSize.value.x - 10) * 10
})
const viewControlsValueXMax = computed(() => {
return stageModelRenderer.value === 'live2d' ? 1000 : (vrmModelSize.value.x + 10) * 10
})
const viewControlsValueY = computed({
get: () => {
switch (stageModelRenderer.value) {
case 'live2d':
return -live2dPosition.value.y
case 'vrm':
return vrmPosition.value.y * 100
default:
return 0
}
},
set: (value) => {
switch (stageModelRenderer.value) {
case 'live2d':
live2dPosition.value.y = -value
break
case 'vrm':
if (vrmControlsDisabled.value)
break
vrmPosition.value.y = value / 100
break
default:
break
}
},
})
const viewControlsValueYMin = computed(() => {
return stageModelRenderer.value === 'live2d' ? -1000 : (-vrmModelSize.value.y - 10) * 10
})
const viewControlsValueYMax = computed(() => {
return stageModelRenderer.value === 'live2d' ? 1000 : (vrmModelSize.value.y + 10) * 10
})
const viewControlsValueZ = computed({
get: () => {
switch (stageModelRenderer.value) {
case 'live2d':
return 0
case 'vrm':
return vrmPosition.value.z
default:
return 0
}
},
set: (value) => {
switch (stageModelRenderer.value) {
case 'live2d':
break
case 'vrm':
if (vrmControlsDisabled.value)
break
vrmPosition.value.z = value
break
default:
break
}
},
})
const viewControlsValueZMin = computed(() => {
return stageModelRenderer.value === 'live2d' ? -500 : -vrmModelSize.value.z - 10
})
const viewControlsValueZMax = computed(() => {
return stageModelRenderer.value === 'live2d' ? 500 : vrmModelSize.value.z + 10
})
const viewControlsValueScale = computed({
get: () => {
if (stageModelRenderer.value === 'live2d') {
return live2dScale.value
}
return vrmCameraDistance.value
},
set: (value) => {
if (stageModelRenderer.value === 'live2d') {
live2dScale.value = value
}
else if (!vrmControlsDisabled.value) {
vrmCameraDistance.value = value
}
},
})
function resetOnMode() {
switch (props.mode) {
case 'x':
viewControlsValueX.value = 0
break
case 'y':
viewControlsValueY.value = 0
break
case 'z':
viewControlsValueZ.value = 0
break
case 'scale':
viewControlsValueScale.value = 1
break
}
}
defineExpose({
resetOnMode,
})
</script>
<template>
<Transition name="fade-side-pops-in">
<div v-if="stageViewControlsEnabled" :class="vrmControlsDisabled ? ['opacity-60', 'pointer-events-none'] : []">
<Transition name="fade-side-pops-in" mode="out-in">
<div v-if="props.mode === 'x'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange v-model="viewControlsValueX" :min="viewControlsValueXMin" :max="viewControlsValueXMax" :step="0.01" :disabled="vrmControlsDisabled" data-direction="vertical" h="50%" write-vertical-left />
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ viewControlsValueX.toFixed(2) }}
</div>
</div>
<div v-else-if="props.mode === 'y'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange v-model="viewControlsValueY" :min="viewControlsValueYMin" :max="viewControlsValueYMax" :step="0.01" :disabled="vrmControlsDisabled" write-vertical-left h="50%" data-direction="vertical" />
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ viewControlsValueY.toFixed(2) }}
</div>
</div>
<div v-else-if="stageModelRenderer === 'vrm' && props.mode === 'z'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange v-model="viewControlsValueZ" :min="viewControlsValueZMin" :max="viewControlsValueZMax" :step="0.01" :disabled="vrmControlsDisabled" write-vertical-left h="50%" data-direction="vertical" />
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ viewControlsValueZ.toFixed(2) }}
</div>
</div>
<div v-else-if="props.mode === 'scale'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange v-model="viewControlsValueScale" :min="0" :max="3" :step="0.0001" :disabled="vrmControlsDisabled" write-vertical-left h="50%" data-direction="vertical" />
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ viewControlsValueScale.toFixed(2) }}
</div>
</div>
</Transition>
</div>
</Transition>
</template>
<style scoped>
.fade-side-pops-in-enter-active,
.fade-side-pops-in-leave-active {
transition: all 0.2s ease-in-out;
}
.fade-side-pops-in-enter-from,
.fade-side-pops-in-leave-to {
opacity: 0;
transform: translateX(-100%) scale(0.8);
}
.fade-side-pops-in-enter-to,
.fade-side-pops-in-leave-from {
opacity: 1;
transform: translateX(0) scale(1);
}
</style>
@@ -3,6 +3,8 @@ import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/mainten
import { useTheme } from '@proj-airi/ui'
import { ref } from 'vue'
import ViewControls from '../Layouts/InteractiveArea/Actions/ViewControls.vue'
import { BackgroundDialogPicker } from '../Backgrounds'
const { cleanupMessages } = useChatMaintenanceStore()
@@ -14,6 +16,7 @@ const backgroundDialogOpen = ref(false)
<template>
<BackgroundDialogPicker v-model="backgroundDialogOpen" />
<div absolute bottom--8 right-0 flex gap-2>
<ViewControls />
<button
class="max-h-[10lh] min-h-[1lh]"
bg="neutral-100 dark:neutral-800"
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { RoundRange } from '@proj-airi/ui'
import { onUnmounted } from 'vue'
import { controlConfig, useL2dViewControl } from '../../stores'
const { scale, position, viewControlsEnabled, viewControlMode } = useL2dViewControl()
onUnmounted(() => {
viewControlsEnabled.value = false
})
</script>
<template>
<Transition name="fade-side-pops-in">
<div v-if="viewControlsEnabled">
<Transition name="fade-side-pops-in" mode="out-in">
<div v-if="viewControlMode === 'x'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="position.x" :min="controlConfig.x.min" :max="controlConfig.x.max" :step="controlConfig.x.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ controlConfig.x.format(position.x) }}
</div>
</div>
<div v-else-if="viewControlMode === 'y'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="position.y" :min="controlConfig.y.min" :max="controlConfig.y.max" :step="controlConfig.y.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ controlConfig.y.format(position.y) }}
</div>
</div>
<div v-else-if="viewControlMode === 'scale'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="scale" :min="controlConfig.scale.min" :max="controlConfig.scale.max" :step="controlConfig.scale.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ controlConfig.scale.format(scale) }}
</div>
</div>
</Transition>
</div>
</Transition>
</template>
<style scoped>
.fade-side-pops-in-enter-active,
.fade-side-pops-in-leave-active {
transition: all 0.2s ease-in-out;
}
.fade-side-pops-in-enter-from,
.fade-side-pops-in-leave-to {
opacity: 0;
transform: translateX(-100%) scale(0.8);
}
.fade-side-pops-in-enter-to,
.fade-side-pops-in-leave-from {
opacity: 1;
transform: translateX(0) scale(1);
}
</style>
@@ -1,13 +1,11 @@
<script setup lang="ts">
import { Screen } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import SliderControls from '../ViewControls/SliderControls.vue'
import Live2DCanvas from './live2d/Canvas.vue'
import Live2DModel from './live2d/Model.vue'
import { useLive2d } from '../../stores/live2d'
import '../../utils/live2d-zip-loader'
import '../../utils/live2d-opfs-registration'
@@ -19,7 +17,6 @@ withDefaults(defineProps<{
mouthOpenSize?: number
focusAt?: { x: number, y: number }
disableFocusAt?: boolean
scale?: number
themeColorsHue?: number
themeColorsHueDynamic?: boolean
live2dIdleAnimationEnabled?: boolean
@@ -33,7 +30,6 @@ withDefaults(defineProps<{
paused: false,
focusAt: () => ({ x: 0, y: 0 }),
mouthOpenSize: 0,
scale: 1,
themeColorsHue: 220.44,
themeColorsHueDynamic: false,
live2dIdleAnimationEnabled: true,
@@ -51,9 +47,6 @@ const componentStateModel = defineModel<'pending' | 'loading' | 'mounted'>('mode
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const live2d = useLive2d()
const { position } = storeToRefs(live2d)
watch([componentStateModel, componentStateCanvas], () => {
componentState.value = (componentStateModel.value === 'mounted' && componentStateCanvas.value === 'mounted')
? 'mounted'
@@ -72,6 +65,9 @@ defineExpose({
<template>
<Screen v-slot="{ width, height }" relative>
<div absolute top-0 z-15 h-full px-3 py="20vh">
<SliderControls />
</div>
<Live2DCanvas
ref="live2dCanvasRef"
v-slot="{ app }"
@@ -92,9 +88,6 @@ defineExpose({
:height="height"
:paused="paused"
:focus-at="focusAt"
:x-offset="position.x"
:y-offset="position.y"
:scale="scale"
:disable-focus-at="disableFocusAt"
:theme-colors-hue="themeColorsHue"
:theme-colors-hue-dynamic="themeColorsHueDynamic"
@@ -26,7 +26,7 @@ import {
useMotionUpdatePluginIdleFocus,
} from '../../../composables/live2d'
import { Emotion, EmotionNeutralMotionName } from '../../../constants/emotions'
import { useLive2d } from '../../../stores/live2d'
import { useL2dViewControl, useLive2d } from '../../../stores/live2d'
const props = withDefaults(defineProps<{
modelSrc?: string
@@ -39,9 +39,6 @@ const props = withDefaults(defineProps<{
paused?: boolean
focusAt?: { x: number, y: number }
disableFocusAt?: boolean
xOffset?: number | string
yOffset?: number | string
scale?: number
themeColorsHue?: number
themeColorsHueDynamic?: boolean
live2dIdleAnimationEnabled?: boolean
@@ -70,17 +67,11 @@ const emits = defineEmits<{
}>()
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
const { position, scale } = useL2dViewControl()
function parsePropsOffset() {
let xOffset = Number.parseFloat(String(props.xOffset)) || 0
let yOffset = Number.parseFloat(String(props.yOffset)) || 0
if (String(props.xOffset).endsWith('%')) {
xOffset = (Number.parseFloat(String(props.xOffset).replace('%', '')) / 100) * props.width
}
if (String(props.yOffset).endsWith('%')) {
yOffset = (Number.parseFloat(String(props.yOffset).replace('%', '')) / 100) * props.height
}
const xOffset = (position.value.x / 100) * props.width
const yOffset = -(position.value.y / 100) * props.height
return {
xOffset,
@@ -131,14 +122,14 @@ function computeScaleAndPosition() {
const heightScale = (props.height * 0.95 / initialModelHeight.value * offsetFactor)
const widthScale = (props.width * 0.95 / initialModelWidth.value * offsetFactor)
let scale = Math.min(heightScale, widthScale)
let minScale = Math.min(heightScale, widthScale)
if (Number.isNaN(scale) || scale <= 0) {
scale = 1e-6
if (Number.isNaN(minScale) || minScale <= 0) {
minScale = 1e-6
}
return {
scale: scale * props.scale,
scale: minScale * scale.value,
x: (props.width / 2) + offset.value.xOffset,
y: props.height + offset.value.yOffset,
}
@@ -549,8 +540,7 @@ watch(modelSrcRef, async () => await loadModel(), { immediate: true })
watch(dark, updateDropShadowFilter, { immediate: true })
watch([model, themeColorsHue], updateDropShadowFilter)
watch(live2dShadowEnabled, updateDropShadowFilter)
watch(offset, () => setScaleAndPosition())
watch(() => props.scale, () => setScaleAndPosition())
watch([offset, scale], () => setScaleAndPosition())
// TODO: This is hacky!
function updateDropShadowFilterLoop() {
@@ -1,2 +1,3 @@
export * from './expression-store'
export * from './live2d'
export * from './view-control'
+6 -10
View File
@@ -1,7 +1,9 @@
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { useBroadcastChannel } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { supportedControl, useL2dViewControl } from './view-control'
type BroadcastChannelEvents
= | BroadcastChannelEventShouldUpdateView
@@ -57,32 +59,25 @@ export const useLive2d = defineStore('live2d', () => {
}
})
const position = useLocalStorageManualReset<{ x: number, y: number }>('settings/live2d/position', { x: 0, y: 0 }) // position is relative to the center of the screen, units are %
const positionInPercentageString = computed(() => ({
x: `${position.value.x}%`,
y: `${position.value.y}%`,
}))
const currentMotion = useLocalStorageManualReset<{ group: string, index?: number }>('settings/live2d/current-motion', () => ({ group: 'Idle', index: 0 }))
const availableMotions = useLocalStorageManualReset<{ motionName: string, motionIndex: number, fileName: string }[]>('settings/live2d/available-motions', () => [])
const motionMap = useLocalStorageManualReset<Record<string, string>>('settings/live2d/motion-map', {})
const scale = useLocalStorageManualReset('settings/live2d/scale', 1)
const { position, scale, reset: resetViewControl } = useL2dViewControl()
// Live2D model parameters
const modelParameters = useLocalStorageManualReset<Record<string, number>>('settings/live2d/parameters', defaultModelParameters)
function resetState() {
position.reset()
supportedControl.forEach(c => resetViewControl(c))
currentMotion.reset()
availableMotions.reset()
motionMap.reset()
scale.reset()
modelParameters.reset()
shouldUpdateView()
}
return {
position,
positionInPercentageString,
currentMotion,
availableMotions,
motionMap,
@@ -94,3 +89,4 @@ export const useLive2d = defineStore('live2d', () => {
resetState,
}
})
export { useL2dViewControl }
@@ -0,0 +1,76 @@
import { useLocalStorage } from '@vueuse/core'
import { ref } from 'vue'
export const supportedControl = ['x', 'y', 'scale'] as const
type SupportedControl = typeof supportedControl[number]
interface ControlConfig { min: number, max: number, step: number, default: number, format: (val: number) => string }
/** show or hide the control element(slider) on stage */
const viewControlsEnabled = ref(false)
/** what value to control for the control element */
const viewControlMode = ref<SupportedControl>('scale')
/** model position relative to the center of the screen, in pixels */
const position = useLocalStorage<{ x: number, y: number }>('settings/live2d/position', { x: 0, y: 0 })
/** model scaling. `1` means no scaling. */
const scale = useLocalStorage('settings/live2d/scale', 1)
const formatPercentD1 = (val: number) => `${val.toFixed(1)}%`
const formatToPercent = (val: number) => `${(val * 100).toFixed(0)}%`
export const controlConfig: Record<SupportedControl, ControlConfig> = {
// TODO: calculate the min and max value dynamically according to window height/width, or allow user to set it
x: {
min: -500,
max: 500,
step: 0.1,
default: 0,
format: formatPercentD1,
},
y: {
min: -500,
max: 500,
step: 0.1,
default: 0,
format: formatPercentD1,
},
scale: {
min: 0.01,
max: 3,
step: 0.01,
default: 1,
format: formatToPercent,
},
}
export function useL2dViewControl() {
/**
* reset the given control to its default value.
* @param key the control to reset
*/
function reset(key: SupportedControl) {
switch (key) {
case 'x':
position.value.x = controlConfig.x.default
break
case 'y':
position.value.y = controlConfig.y.default
break
case 'scale':
scale.value = controlConfig.scale.default
break
}
}
return {
/** model position relative to the center of the screen, in pixels */
position,
/** model scaling in percentages. `100` means no scaling. */
scale,
/** reset the given control to its default value. */
reset,
/** show or hide the control element(slider) on stage */
viewControlsEnabled,
/** what value to control for the control element */
viewControlMode,
}
}
@@ -2,6 +2,7 @@
/*
* - Extend OrbitControls from three
* - Define camera behavior
* - TODO: implement the control elements and replace the <slot/>
*/
import type { Vec3 } from '../../stores/model-store'
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { RoundRange } from '@proj-airi/ui'
import { onUnmounted } from 'vue'
import { controlConfig as conf, useThreeViewControl } from '../../stores/view-control'
const { cameraDistance, cameraFOV, modelOffset, viewControlsEnabled, viewControlMode } = useThreeViewControl()
onUnmounted(() => {
viewControlsEnabled.value = false
})
</script>
<template>
<Transition name="fade-side-pops-in">
<div v-if="viewControlsEnabled">
<Transition name="fade-side-pops-in" mode="out-in">
<!-- TODO: generate the controls programmatically, while preserving the transition -->
<div v-if="viewControlMode === 'x'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="modelOffset.x" :min="conf.x.min" :max="conf.x.max" :step="conf.x.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ conf.x.format(modelOffset.x) }}
</div>
</div>
<div v-else-if="viewControlMode === 'y'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="modelOffset.y" :min="conf.y.min" :max="conf.y.max" :step="conf.y.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ conf.y.format(modelOffset.y) }}
</div>
</div>
<div v-else-if="viewControlMode === 'z'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="modelOffset.z" :min="conf.z.min" :max="conf.z.max" :step="conf.z.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ conf.z.format(modelOffset.z) }}
</div>
</div>
<div v-else-if="viewControlMode === 'cameraDistance'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="cameraDistance" :min="conf.cameraDistance.min" :max="conf.cameraDistance.max" :step="conf.cameraDistance.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ conf.cameraDistance.format(cameraDistance) }}
</div>
</div>
<div v-else-if="viewControlMode === 'cameraFOV'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="cameraFOV" :min="conf.cameraFOV.min" :max="conf.cameraFOV.max" :step="conf.cameraFOV.step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ conf.cameraFOV.format(cameraFOV) }}
</div>
</div>
</Transition>
</div>
</Transition>
</template>
<style scoped>
.fade-side-pops-in-enter-active,
.fade-side-pops-in-leave-active {
transition: all 0.2s ease-in-out;
}
.fade-side-pops-in-enter-from,
.fade-side-pops-in-leave-to {
opacity: 0;
transform: translateX(-100%) scale(0.8);
}
.fade-side-pops-in-enter-to,
.fade-side-pops-in-leave-from {
opacity: 1;
transform: translateX(0) scale(1);
}
</style>
@@ -1 +1,2 @@
export { default as OrbitControls } from './OrbitControls.vue'
export { default as SliderControls } from './SliderControls.vue'
@@ -43,7 +43,7 @@ import {
stageThreeTraceThreeSceneSubtreeEvent,
stageThreeTraceThreeSceneTransactionEvent,
} from '../trace'
import { OrbitControls } from './Controls'
import { OrbitControls, SliderControls } from './Controls'
import { SkyBox } from './Environment'
import { VRMModel } from './Model'
@@ -708,7 +708,10 @@ defineExpose({
</script>
<template>
<Screen v-slot="{ width, height }">
<Screen v-slot="{ width, height }" relative>
<div top="50%" translate-y="[-50%]" fixed z-15 px-3>
<SliderControls />
</div>
<TresCanvas
:width="width"
:height="height"
+2
View File
@@ -1,6 +1,8 @@
export { default as ThreeScene } from './components/ThreeScene.vue'
export * from './composables/hit-test'
export * from './composables/render-target'
export { useModelStore } from './stores/model-store'
export * from './stores/view-control'
export * from './trace'
export * from './utils/vrm-preview'
@@ -6,6 +6,8 @@ import { computed, ref, watch } from 'vue'
import defaultSkyBoxSrc from '../components/Environment/assets/sky_linekotsi_23_HDRI.hdr?url'
import { supportedControl, useThreeViewControl } from './view-control'
// TODO: this is for future type injection features
// TODO: make a separate type.ts
export interface Vec3 { x: number, y: number, z: number }
@@ -77,6 +79,7 @@ interface BroadcastChannelEventShouldUpdateView {
const vrmViewUpdateRuntimeInstanceId = Math.random().toString(36).slice(2, 10)
let vrmViewUpdateMessageSequence = 0
const { cameraFOV, cameraDistance, modelOffset, reset: resetViewControl } = useThreeViewControl()
export const useModelStore = defineStore('modelStore', () => {
const { post, data } = useBroadcastChannel<BroadcastChannelEvents, BroadcastChannelEvents>({ name: 'airi-stores-stage-ui-three-vrm' })
@@ -131,9 +134,6 @@ export const useModelStore = defineStore('modelStore', () => {
}
// === Legacy / shared controls ===
// REVIEW: `scale` is still shared with non-VRM view controls. The VRM path should
// gradually move to `cameraDistance` as the primary user-facing zoom concept.
const scale = useLocalStorage('settings/stage-ui-three/scale', 1)
const lastCommittedModelSrc = useLocalStorage('settings/stage-ui-three/lastModelSrc', '')
// === Model lifecycle / bootstrap ===
@@ -145,9 +145,7 @@ export const useModelStore = defineStore('modelStore', () => {
// === User scene settings ===
// These values are intended to survive model reloads and direct edits from settings UI.
const modelOffset = useLocalStorage('settings/stage-ui-three/modelOffset', { x: 0, y: 0, z: 0 })
const modelRotationY = useLocalStorage('settings/stage-ui-three/modelRotationY', 0)
const cameraFOV = useLocalStorage('settings/stage-ui-three/cameraFOV', 40)
const trackingMode = useLocalStorage('settings/stage-ui-three/trackingMode', 'none' as 'camera' | 'mouse' | 'none')
// === View state ===
@@ -155,7 +153,6 @@ export const useModelStore = defineStore('modelStore', () => {
// `lookAtTarget` represent the current runtime pose and may be recalculated when
// a new model bootstrap is applied.
const cameraPosition = useLocalStorage('settings/stage-ui-three/camera-position', { x: 0, y: 0, z: -1 })
const cameraDistance = useLocalStorage('settings/stage-ui-three/cameraDistance', 0)
const lookAtTarget = useLocalStorage('settings/stage-ui-three/lookAtTarget', { x: 0, y: 0, z: 0 })
function resetModelStore() {
@@ -165,12 +162,10 @@ export const useModelStore = defineStore('modelStore', () => {
lastCommittedModelSrc.value = ''
modelSize.value = { x: 0, y: 0, z: 0 }
modelOrigin.value = { x: 0, y: 0, z: 0 }
modelOffset.value = { x: 0, y: 0, z: 0 }
modelRotationY.value = 0
cameraFOV.value = 40
cameraPosition.value = { x: 0, y: 0, z: 0 }
cameraDistance.value = 0
supportedControl.forEach(c => resetViewControl(c))
lookAtTarget.value = { x: 0, y: 0, z: 0 }
trackingMode.value = 'none'
@@ -218,7 +213,6 @@ export const useModelStore = defineStore('modelStore', () => {
sceneTransactionDepth,
sceneMutationLocked,
scale,
lastCommittedModelSrc,
modelSize,
@@ -0,0 +1,103 @@
import { useLocalStorage } from '@vueuse/core'
import { ref } from 'vue'
export const supportedControl = ['x', 'y', 'z', 'cameraDistance', 'cameraFOV'] as const
type SupportedControl = typeof supportedControl[number]
interface ControlConfig { min: number, max: number, step: number, default: number, format: (val: number) => string }
const formatDecimal2Meters = (val: number) => `${val.toFixed(2)}m`
export const controlConfig: Record<SupportedControl, ControlConfig> = {
// TODO: allow user to set the min/max value
x: {
min: -10,
max: 10,
step: 0.01,
default: 0,
format: formatDecimal2Meters,
},
y: {
min: -10,
max: 10,
step: 0.01,
default: 0,
format: formatDecimal2Meters,
},
z: {
min: -10,
max: 10,
step: 0.01,
default: 0,
format: formatDecimal2Meters,
},
cameraDistance: {
min: 0,
max: 10,
step: 0.01,
default: 1,
format: formatDecimal2Meters,
},
cameraFOV: {
min: 10,
max: 120,
step: 1,
default: 40,
format: (val: number) => `${val.toFixed(0)}°`,
},
}
/** camera field of view, in degrees. */
const cameraFOV = useLocalStorage('settings/stage-ui-three/cameraFOV', 40)
/**
* euclidean distance between the model center and the camera center, in meters.
* setting this value will move the camera along the axis.
*/
const cameraDistance = useLocalStorage('settings/stage-ui-three/cameraDistance', 1)
/** model position from the scene origin, in meters. */
const modelOffset = useLocalStorage('settings/stage-ui-three/modelOffset', { x: 0, y: 0, z: 0 })
/** show or hide the control element(slider) on HUD. */
const viewControlsEnabled = ref(false)
/** what value to control for the control element */
const viewControlMode = ref<SupportedControl>('cameraDistance')
/**
* reset the given control to its default value.
* @param key the control to reset
*/
function reset(key: SupportedControl) {
switch (key) {
case 'x':
modelOffset.value.x = controlConfig.x.default
break
case 'y':
modelOffset.value.y = controlConfig.y.default
break
case 'z':
modelOffset.value.z = controlConfig.z.default
break
case 'cameraDistance':
cameraDistance.value = controlConfig.cameraDistance.default
break
case 'cameraFOV':
cameraFOV.value = controlConfig.cameraFOV.default
break
}
}
export function useThreeViewControl() {
return {
/** camera field of view, in degrees. */
cameraFOV,
/** euclidean distance between the model center and the camera center, in meters. */
cameraDistance,
/** model position from the scene origin, in meters. */
modelOffset,
/** show or hide the control element(slider) on HUD. */
viewControlsEnabled,
/** what value to control for the control element */
viewControlMode,
/** reset the given control to its default value. */
reset,
}
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ModelSettingsRuntimeSnapshot } from './runtime'
import { Live2DScene, useLive2d } from '@proj-airi/stage-ui-live2d'
import { Live2DScene } from '@proj-airi/stage-ui-live2d'
import { ThreeScene, useModelStore } from '@proj-airi/stage-ui-three'
import { useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
@@ -24,7 +24,6 @@ const emit = defineEmits<{
const positionCursor = useMouse()
const settingsStore = useSettings()
const live2dStore = useLive2d()
const modelStore = useModelStore()
const live2dSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>()
const vrmSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>()
@@ -45,7 +44,6 @@ const {
live2dMaxFps,
live2dRenderScale,
} = storeToRefs(settingsStore)
const { scale: live2dScale } = storeToRefs(live2dStore)
const { sceneMutationLocked, scenePhase } = storeToRefs(modelStore)
const live2dSceneClassList = computed(() => normalizeClassList(props.live2dSceneClass))
@@ -141,7 +139,6 @@ defineExpose({
:model-src="stageModelSelectedUrl"
:model-id="stageModelSelected"
:disable-focus-at="live2dDisableFocus"
:scale="live2dScale"
:theme-colors-hue="themeColorsHue"
:theme-colors-hue-dynamic="themeColorsHueDynamic"
:live2d-idle-animation-enabled="live2dIdleAnimationEnabled"
@@ -42,9 +42,6 @@ import { shouldRunLive2dLipSyncLoop } from './runtime'
const props = withDefaults(defineProps<{
paused?: boolean
focusAt: { x: number, y: number }
xOffset?: number | string
yOffset?: number | string
scale?: number
}>(), { paused: false, scale: 1 })
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
@@ -652,9 +649,6 @@ defineExpose({
:focus-at="focusAt"
:mouth-open-size="mouthOpenSize"
:paused="paused"
:x-offset="xOffset"
:y-offset="yOffset"
:scale="scale"
:disable-focus-at="live2dDisableFocus"
:theme-colors-hue="themeColorsHue"
:theme-colors-hue-dynamic="themeColorsHueDynamic"
@@ -2,6 +2,7 @@ import type { ChatSessionsExport } from '../types/chat-session'
import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { useLive2d } from '@proj-airi/stage-ui-live2d'
import { useModelStore } from '@proj-airi/stage-ui-three'
import { useChatOrchestratorStore } from '../stores/chat'
import { useChatSessionStore } from '../stores/chat/session-store'
@@ -27,6 +28,7 @@ export function useDataMaintenance() {
const settingsStore = useSettings()
const audioSettingsStore = useSettingsAudioDevice()
const live2dStore = useLive2d()
const threeStore = useModelStore()
const hearingStore = useHearingStore()
const speechStore = useSpeechStore()
const consciousnessStore = useConsciousnessStore()
@@ -84,6 +86,7 @@ export function useDataMaintenance() {
await settingsStore.resetState()
audioSettingsStore.resetState()
live2dStore.resetState()
threeStore.resetModelStore()
mcpStore.resetState()
onboardingStore.resetSetupState()
airiCardStore.resetState()
+1 -1
View File
@@ -1 +1 @@
export * from '@proj-airi/stage-ui-live2d/stores/live2d'
export * from '@proj-airi/stage-ui-live2d/stores'
+1
View File
@@ -0,0 +1 @@
export { supportedControl, useThreeViewControl } from '@proj-airi/stage-ui-three'
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useElementHover, useKeyModifier } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
@@ -6,6 +7,7 @@ const props = withDefaults(defineProps<{
max?: number
step?: number
disabled?: boolean
handleWheel?: boolean
}>(), {
min: 0,
max: 100,
@@ -13,17 +15,22 @@ const props = withDefaults(defineProps<{
disabled: false,
})
const smoothingFactor = 10000
const modelValue = defineModel<number>({ required: true })
const scaledMin = computed(() => props.min * 10000)
const scaledMax = computed(() => props.max * 10000)
const scaledStep = computed(() => props.step * 10000)
const sliderRef = ref<HTMLInputElement>()
const scaledMin = computed(() => props.min * smoothingFactor)
const scaledMax = computed(() => props.max * smoothingFactor)
const scaledStep = computed(() => props.step * smoothingFactor)
const shiftPressed = useKeyModifier('Shift')
const isHovered = useElementHover(sliderRef)
const sliderValue = computed({
get: () => modelValue.value * 10000,
get: () => modelValue.value * smoothingFactor,
set: (value: number) => {
modelValue.value = value / 10000
modelValue.value = value / smoothingFactor
updateTrackColor()
},
})
@@ -31,6 +38,15 @@ const sliderValue = computed({
onMounted(() => updateTrackColor())
watch(sliderValue, () => updateTrackColor(), { immediate: true })
watch([scaledMin, scaledMax, scaledStep], () => updateTrackColor(), { immediate: true })
watch(isHovered, (hovered: boolean) => {
if (!props.handleWheel)
return
if (hovered) {
sliderRef.value?.addEventListener('wheel', onWheelInput)
return
}
sliderRef.value?.removeEventListener('wheel', onWheelInput)
})
function updateTrackColor() {
if (!sliderRef.value) {
@@ -46,6 +62,15 @@ function handleInput(e: Event) {
const target = e.target as HTMLInputElement
target.style.setProperty('--value', target.value)
}
function onWheelInput(ev: WheelEvent) {
let valueAfter = 0
if (ev.deltaY < 0)
valueAfter = modelValue.value += props.step * (shiftPressed.value ? 50 : 1)
if (ev.deltaY > 0)
valueAfter = modelValue.value -= props.step * (shiftPressed.value ? 50 : 1)
modelValue.value = Math.min(Math.max(valueAfter, props.min), props.max)
}
</script>
<template>
@@ -66,21 +91,23 @@ function handleInput(e: Event) {
/*generated with Input range slider CSS style generator (version 20211225)
https://toughengineer.github.io/demo/slider-styler*/
.form_input-round-range {
--height: 2em;
--height: 100%;
--width: 2rem;
min-height: var(--height);
min-height: var(--width);
appearance: none;
background: transparent;
transition: background-color 0.2s ease;
--thumb-width: var(--height);
--thumb-height: var(--height);
--thumb-width: var(--width);
--thumb-height: var(--width);
--thumb-box-shadow: none;
--thumb-border: none;
--thumb-border-radius: 0px;
--thumb-background: transparent;
--track-height: calc(var(--height) - var(--track-value-padding) * 2);
--track-width: var(--width);
--track-box-shadow: 0 0 12px -2px rgb(0 0 0 / 22%);
--track-border: none;
--track-border-radius: 16px;
@@ -95,8 +122,6 @@ https://toughengineer.github.io/demo/slider-styler*/
}
.dark .form_input-round-range {
--thumb-background: rgb(238, 238, 238);
--track-border: none;
--track-background: rgba(64, 64, 64, 0.7);
--track-box-shadow: 0 0 12px -2px rgb(0 0 0 / 22%);