feat(stage-layouts): add mobile view adjustment mode (#2475)
This commit is contained in:
@@ -17,6 +17,7 @@ mobile-tools:
|
||||
settings: All settings
|
||||
about: About
|
||||
view: View
|
||||
close-view: Save and close view controls
|
||||
hearing: Hearing
|
||||
chat:
|
||||
actions:
|
||||
|
||||
@@ -16,7 +16,8 @@ mobile-tools:
|
||||
background: 背景
|
||||
settings: 全部设置
|
||||
about: 关于
|
||||
view: 视角
|
||||
view: 视图
|
||||
close-view: 保存并关闭视图调节
|
||||
hearing: 听觉
|
||||
chat:
|
||||
actions:
|
||||
|
||||
+54
-30
@@ -2,38 +2,79 @@
|
||||
import { defaultControlConfig as threeCtrlConf, supportedControl as threeSupportedControl, useThreeViewControl } from '@proj-airi/stage-ui-three'
|
||||
import { defaultControlConfig as l2dCtrlConf, supportedControl as l2dSupportedCtrl, useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
|
||||
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
|
||||
import { Button, GhostButton } from '@proj-airi/ui'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
variant?: 'desktop' | 'mobile-stage'
|
||||
}>(), {
|
||||
variant: 'desktop',
|
||||
})
|
||||
|
||||
const { stageModelRenderer } = storeToRefs(useSettingsStageModel())
|
||||
const { viewControlsEnabled: l2dViewCtrlEnabled, viewControlMode: l2dCtrlMode, set: l2dSet } = useL2dViewControl()
|
||||
const { viewControlsEnabled: threeSliderCtrlEnabled, viewControlMode: threeCtrlMode, set: threeSet } = useThreeViewControl()
|
||||
const controlEnabled = computed(() => {
|
||||
if (stageModelRenderer.value === 'live2d')
|
||||
return { enabled: l2dViewCtrlEnabled, mode: l2dCtrlMode, supported: l2dSupportedCtrl, conf: l2dCtrlConf, reset: l2dSet }
|
||||
return { enabled: l2dViewCtrlEnabled, mode: l2dCtrlMode }
|
||||
if (stageModelRenderer.value === 'vrm')
|
||||
return { enabled: threeSliderCtrlEnabled, mode: threeCtrlMode, supported: threeSupportedControl, conf: threeCtrlConf, reset: threeSet }
|
||||
return { enabled: threeSliderCtrlEnabled, mode: threeCtrlMode }
|
||||
return null
|
||||
})
|
||||
|
||||
const visibleControls = computed(() => {
|
||||
if (!controlEnabled.value)
|
||||
return []
|
||||
|
||||
if (stageModelRenderer.value === 'live2d')
|
||||
return l2dSupportedCtrl
|
||||
|
||||
return threeSupportedControl
|
||||
})
|
||||
|
||||
function isL2dControl(targetMode: string): targetMode is typeof l2dSupportedCtrl[number] {
|
||||
return l2dSupportedCtrl.includes(targetMode as typeof l2dSupportedCtrl[number])
|
||||
}
|
||||
|
||||
function isThreeControl(targetMode: string): targetMode is typeof threeSupportedControl[number] {
|
||||
return threeSupportedControl.includes(targetMode as typeof threeSupportedControl[number])
|
||||
}
|
||||
|
||||
function controlLabel(control: string) {
|
||||
if (stageModelRenderer.value === 'live2d' && isL2dControl(control))
|
||||
return l2dCtrlConf[control].buttonText
|
||||
|
||||
if (stageModelRenderer.value === 'vrm' && isThreeControl(control))
|
||||
return threeCtrlConf[control].buttonText
|
||||
|
||||
return control
|
||||
}
|
||||
|
||||
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)
|
||||
if (stageModelRenderer.value === 'live2d' && isL2dControl(targetMode)) {
|
||||
if (l2dCtrlMode.value === targetMode)
|
||||
l2dSet(targetMode)
|
||||
else
|
||||
l2dCtrlMode.value = targetMode
|
||||
return
|
||||
}
|
||||
controlEnabled.value.mode.value = targetMode as any
|
||||
|
||||
if (stageModelRenderer.value === 'vrm' && isThreeControl(targetMode)) {
|
||||
if (threeCtrlMode.value === targetMode)
|
||||
threeSet(targetMode)
|
||||
else
|
||||
threeCtrlMode.value = targetMode
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['w-full flex items-center self-end justify-end gap-2', $slots.default ? 'flex-col' : 'flex-1']">
|
||||
<div :class="['w-full flex items-center self-end justify-end gap-2', props.variant === 'desktop' && 'flex-1']">
|
||||
<Transition name="fade">
|
||||
<div v-if="controlEnabled?.enabled.value" :class="['w-full flex justify-between gap-2', $slots.default && 'px-4 pb-4']">
|
||||
<div v-if="controlEnabled?.enabled.value" :class="['w-full flex justify-between gap-2']">
|
||||
<Button
|
||||
v-for="control in controlEnabled.supported"
|
||||
v-for="control in visibleControls"
|
||||
:key="control"
|
||||
:aria-pressed="controlEnabled.mode.value === control"
|
||||
:color="controlEnabled.mode.value === control ? 'primary' : 'neutral'"
|
||||
@@ -41,24 +82,12 @@ function handleViewControlsToggle(targetMode: string) {
|
||||
block
|
||||
@click="handleViewControlsToggle(control)"
|
||||
>
|
||||
{{ (controlEnabled.conf as any)[control].buttonText }}
|
||||
{{ controlLabel(control) }}
|
||||
</Button>
|
||||
</div>
|
||||
</Transition>
|
||||
<GhostButton
|
||||
v-if="$slots.default"
|
||||
block size="unset"
|
||||
:disabled="!controlEnabled"
|
||||
:aria-expanded="controlEnabled?.enabled.value ?? false"
|
||||
:class="['mobile-tool-row order-first min-h-15 rounded-none px-4 py-3']"
|
||||
@click="controlEnabled && (controlEnabled.enabled.value = !controlEnabled.enabled.value)"
|
||||
>
|
||||
<span aria-hidden="true" :class="['i-solar:tuning-outline size-5 shrink-0 text-neutral-400']" />
|
||||
<span :class="['flex-1 text-left text-sm']"><slot /></span>
|
||||
<span aria-hidden="true" :class="['size-4 text-neutral-400', controlEnabled?.enabled.value ? 'i-solar:alt-arrow-up-outline' : 'i-solar:alt-arrow-down-outline']" />
|
||||
</GhostButton>
|
||||
<button
|
||||
v-else
|
||||
v-if="props.variant === 'desktop'"
|
||||
w-fit flex items-center self-end justify-center justify-self-end rounded-xl p-2 backdrop-blur-md
|
||||
border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" title="View"
|
||||
text="neutral-500 dark:neutral-400"
|
||||
@@ -73,11 +102,6 @@ function handleViewControlsToggle(targetMode: string) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-tool-row :deep(.basic-button-content) {
|
||||
width: 100%;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store
|
||||
import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
|
||||
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
|
||||
import { BasicButton, BasicTextarea } from '@proj-airi/ui'
|
||||
import { onLongPress, useEventListener, usePointerSwipe } from '@vueuse/core'
|
||||
import { animate, spring } from 'animejs'
|
||||
@@ -21,6 +22,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, nextTick, onMounted, onUnmounted, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ViewControls from './InteractiveArea/Actions/ViewControls.vue'
|
||||
import MobileSettingsDrawer from './mobile-settings-drawer.vue'
|
||||
import MobileHeader from './MobileHeader.vue'
|
||||
|
||||
@@ -107,7 +109,7 @@ const mobileInteractiveAreaClass = [
|
||||
]
|
||||
const chatHistoryClass = [
|
||||
'pointer-events-auto relative z-20',
|
||||
'max-w-[calc(100%_-_3.5rem)] w-full self-start pb-3 pl-3',
|
||||
'w-full self-start px-3 pb-3',
|
||||
]
|
||||
const controlsIslandClass = computed(() => [
|
||||
'absolute right-0 translate-y-[-100%]',
|
||||
@@ -120,8 +122,17 @@ const controlsIslandClass = computed(() => [
|
||||
],
|
||||
])
|
||||
const { themeColorsHueDynamic } = storeToRefs(useSettings())
|
||||
const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl()
|
||||
const { viewControlsEnabled: threeViewCtrlEnabled } = useThreeViewControl()
|
||||
const { stageModelRenderer } = storeToRefs(useSettingsStageModel())
|
||||
const l2dViewControl = useL2dViewControl()
|
||||
const threeViewControl = useThreeViewControl()
|
||||
const viewControlsAvailable = computed(() => stageModelRenderer.value === 'live2d' || stageModelRenderer.value === 'vrm')
|
||||
const viewControlsEnabled = computed(() => {
|
||||
if (stageModelRenderer.value === 'live2d')
|
||||
return l2dViewControl.viewControlsEnabled.value
|
||||
if (stageModelRenderer.value === 'vrm')
|
||||
return threeViewControl.viewControlsEnabled.value
|
||||
return false
|
||||
})
|
||||
const settingsAudioDevice = useSettingsAudioDevice()
|
||||
const { enabled, stream } = storeToRefs(settingsAudioDevice)
|
||||
const { t } = useI18n()
|
||||
@@ -129,6 +140,55 @@ const { audioContext } = useAudioContext()
|
||||
const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
|
||||
let analyzerSource: MediaStreamAudioSourceNode | undefined
|
||||
|
||||
async function openViewControls() {
|
||||
closeViewControls()
|
||||
|
||||
if (stageModelRenderer.value === 'live2d')
|
||||
l2dViewControl.viewControlsEnabled.value = true
|
||||
else if (stageModelRenderer.value === 'vrm')
|
||||
threeViewControl.viewControlsEnabled.value = true
|
||||
|
||||
await nextTick()
|
||||
mobileInteractiveArea.value
|
||||
?.querySelector<HTMLButtonElement>('[data-testid="view-controls-close-button"]')
|
||||
?.focus()
|
||||
}
|
||||
|
||||
function closeViewControls() {
|
||||
l2dViewControl.viewControlsEnabled.value = false
|
||||
threeViewControl.viewControlsEnabled.value = false
|
||||
}
|
||||
|
||||
async function exitViewControls() {
|
||||
closeViewControls()
|
||||
await nextTick()
|
||||
mobileInteractiveArea.value
|
||||
?.querySelector<HTMLButtonElement>('[data-testid="mobile-settings-button"]')
|
||||
?.focus()
|
||||
}
|
||||
|
||||
watch(stageModelRenderer, (renderer) => {
|
||||
const exitsViewControls = (renderer !== 'live2d' && l2dViewControl.viewControlsEnabled.value)
|
||||
|| (renderer !== 'vrm' && threeViewControl.viewControlsEnabled.value)
|
||||
if (exitsViewControls) {
|
||||
void exitViewControls()
|
||||
return
|
||||
}
|
||||
|
||||
if (renderer !== 'live2d')
|
||||
l2dViewControl.viewControlsEnabled.value = false
|
||||
if (renderer !== 'vrm')
|
||||
threeViewControl.viewControlsEnabled.value = false
|
||||
}, { immediate: true })
|
||||
|
||||
function handleViewControlsKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || !viewControlsEnabled.value)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
void exitViewControls()
|
||||
}
|
||||
|
||||
function isMobileDevice() {
|
||||
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||||
}
|
||||
@@ -388,8 +448,9 @@ onUnmounted(() => {
|
||||
data-testid="mobile-interactive-area"
|
||||
:class="mobileInteractiveAreaClass"
|
||||
:style="mobileInteractiveAreaStyle"
|
||||
@keydown="handleViewControlsKeydown"
|
||||
>
|
||||
<MobileHeader>
|
||||
<MobileHeader v-if="!viewControlsEnabled">
|
||||
<BasicButton
|
||||
size="unset"
|
||||
data-testid="conversation-selector-button"
|
||||
@@ -407,7 +468,27 @@ onUnmounted(() => {
|
||||
<span aria-hidden="true" :class="['i-solar:dialog-2-outline size-6']" />
|
||||
</BasicButton>
|
||||
<CharacterSwitcherDrawer />
|
||||
<MobileSettingsDrawer v-model:character-voice-enabled="characterVoiceEnabled" />
|
||||
<MobileSettingsDrawer
|
||||
v-model:character-voice-enabled="characterVoiceEnabled"
|
||||
:view-controls-available="viewControlsAvailable"
|
||||
@open-view-controls="openViewControls"
|
||||
/>
|
||||
</MobileHeader>
|
||||
<MobileHeader v-else>
|
||||
<BasicButton
|
||||
size="unset"
|
||||
data-testid="view-controls-close-button"
|
||||
:title="t('stage.mobile-tools.close-view')"
|
||||
:aria-label="t('stage.mobile-tools.close-view')"
|
||||
:class="[
|
||||
'pointer-events-auto ml-auto size-11 rounded-full backdrop-blur-md',
|
||||
'bg-neutral-50/70 text-neutral-600 dark:bg-neutral-900/70 dark:text-neutral-300',
|
||||
'focus-visible:outline-2 focus-visible:outline-primary-500',
|
||||
]"
|
||||
@click="exitViewControls"
|
||||
>
|
||||
<span aria-hidden="true" :class="['i-solar:close-circle-outline size-6']" />
|
||||
</BasicButton>
|
||||
</MobileHeader>
|
||||
<div
|
||||
:class="[
|
||||
@@ -417,7 +498,7 @@ onUnmounted(() => {
|
||||
<KeepAlive>
|
||||
<Transition name="fade">
|
||||
<ChatHistory
|
||||
v-if="!threeViewCtrlEnabled && !l2dViewCtrlEnabled"
|
||||
v-if="!viewControlsEnabled"
|
||||
variant="mobile"
|
||||
:messages="historyMessages"
|
||||
:sending="isActiveSessionSending"
|
||||
@@ -432,6 +513,7 @@ onUnmounted(() => {
|
||||
</KeepAlive>
|
||||
</div>
|
||||
<div
|
||||
v-show="!viewControlsEnabled"
|
||||
ref="interactionControls"
|
||||
data-testid="mobile-interaction-controls"
|
||||
:class="[
|
||||
@@ -552,5 +634,16 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-show="viewControlsEnabled"
|
||||
data-testid="view-controls-toolbar"
|
||||
:class="[
|
||||
'pointer-events-auto fixed inset-x-0 z-30',
|
||||
'pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))]',
|
||||
'bottom-[max(1rem,env(safe-area-inset-bottom))]',
|
||||
]"
|
||||
>
|
||||
<ViewControls variant="mobile-stage" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,10 +9,15 @@ import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
|
||||
import ActionAbout from './InteractiveArea/Actions/About.vue'
|
||||
import ViewControls from './InteractiveArea/Actions/ViewControls.vue'
|
||||
|
||||
import { BackgroundDialogPicker } from '../Backgrounds'
|
||||
|
||||
const props = defineProps<{
|
||||
viewControlsAvailable: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
openViewControls: []
|
||||
}>()
|
||||
const characterVoiceEnabled = defineModel<boolean>('characterVoiceEnabled', { required: true })
|
||||
const { t } = useI18n()
|
||||
const { isDark } = useTheme()
|
||||
@@ -25,9 +30,9 @@ const backgroundDialogOpen = shallowRef(false)
|
||||
const settingsOpen = shallowRef(false)
|
||||
const aboutOpen = shallowRef(false)
|
||||
// Finish closing settings before opening a sibling modal, so focus and scroll locks have one owner.
|
||||
const nextPanel = shallowRef<'background' | 'about' | 'account' | 'hearing'>()
|
||||
const nextPanel = shallowRef<'background' | 'about' | 'account' | 'hearing' | 'view'>()
|
||||
|
||||
function openPanel(panel: 'background' | 'about' | 'account' | 'hearing') {
|
||||
function openPanel(panel: 'background' | 'about' | 'account' | 'hearing' | 'view') {
|
||||
nextPanel.value = panel
|
||||
settingsOpen.value = false
|
||||
}
|
||||
@@ -48,6 +53,9 @@ function finishSettingsClose() {
|
||||
else
|
||||
authStore.needsLogin = true
|
||||
}
|
||||
else if (nextPanel.value === 'view') {
|
||||
emit('openViewControls')
|
||||
}
|
||||
nextPanel.value = undefined
|
||||
}
|
||||
|
||||
@@ -112,9 +120,16 @@ watch(hearingOpen, async (open) => {
|
||||
<span aria-hidden="true" :class="['i-solar:alt-arrow-right-outline size-4 text-neutral-400']" />
|
||||
</GhostButton>
|
||||
<div :class="['mx-4 border-t border-neutral-100 dark:border-neutral-700/50']" />
|
||||
<ViewControls>
|
||||
{{ t('stage.mobile-tools.view') }}
|
||||
</ViewControls>
|
||||
<GhostButton
|
||||
block size="unset"
|
||||
:disabled="!props.viewControlsAvailable"
|
||||
:class="['mobile-tool-row min-h-13 rounded-none px-4 py-3']"
|
||||
@click="openPanel('view')"
|
||||
>
|
||||
<span aria-hidden="true" :class="['i-solar:tuning-outline size-5 shrink-0 text-neutral-400']" />
|
||||
<span :class="['flex-1 text-left text-sm']">{{ t('stage.mobile-tools.view') }}</span>
|
||||
<span aria-hidden="true" :class="['i-solar:alt-arrow-right-outline size-4 text-neutral-400']" />
|
||||
</GhostButton>
|
||||
</div>
|
||||
</section>
|
||||
<section :class="['mb-4']">
|
||||
|
||||
Reference in New Issue
Block a user