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
@@ -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"