feat(stage-layouts): add mobile view adjustment mode (#2475)
This commit is contained in:
@@ -7,10 +7,13 @@ import MobileInteractiveArea from '@proj-airi/stage-layouts/components/Layouts/M
|
||||
import ChatArea from '@proj-airi/stage-layouts/components/Widgets/ChatArea'
|
||||
|
||||
import { PiniaColada } from '@pinia/colada'
|
||||
import { useThreeViewControl } from '@proj-airi/stage-ui-three'
|
||||
import { browserAuthorizationHandler, registerAuthorizationHandler } from '@proj-airi/stage-ui/libs/auth'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
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 { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
|
||||
import { createPinia } from 'pinia'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render } from 'vitest-browser-vue'
|
||||
@@ -35,6 +38,8 @@ function createTestI18n() {
|
||||
}
|
||||
|
||||
async function renderArea(component: Component = InteractiveArea) {
|
||||
useL2dViewControl().viewControlsEnabled.value = false
|
||||
useThreeViewControl().viewControlsEnabled.value = false
|
||||
const sessionB: ChatSessionMeta = {
|
||||
sessionId: 'session-b',
|
||||
userId: 'local',
|
||||
@@ -73,6 +78,7 @@ async function renderArea(component: Component = InteractiveArea) {
|
||||
chat: useChatStore(pinia),
|
||||
chatSession: useChatSessionStore(pinia),
|
||||
chatStream: useChatStreamStore(pinia),
|
||||
stageModel: useSettingsStageModel(pinia),
|
||||
screen,
|
||||
}
|
||||
}
|
||||
@@ -173,6 +179,171 @@ describe('interactive area synchronized state', () => {
|
||||
await expect.element(screen.getByRole('dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
it('uses the full mobile width for chat history after removing the action rail', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The removed right action rail left a fixed 3.5rem reservation on the
|
||||
// chat history. Long messages and the scrollbar still stopped before the
|
||||
// right edge even though the controls no longer occupied that space.
|
||||
await page.viewport(390, 844)
|
||||
const { screen } = await renderArea(MobileInteractiveArea)
|
||||
const history = screen.container.querySelector<HTMLElement>('.chat-history')
|
||||
|
||||
expect(history).not.toBeNull()
|
||||
expect(history!.getBoundingClientRect().width).toBe(390)
|
||||
})
|
||||
|
||||
it('opens view controls on the stage and closes them from the top right', async () => {
|
||||
await page.viewport(390, 844)
|
||||
const viewControl = useL2dViewControl()
|
||||
viewControl.viewControlsEnabled.value = false
|
||||
|
||||
const { screen, stageModel } = await renderArea(MobileInteractiveArea)
|
||||
stageModel.setStageModelRenderer('live2d')
|
||||
|
||||
await screen.getByTestId('mobile-settings-button').click()
|
||||
await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click()
|
||||
|
||||
await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.title' })).not.toBeInTheDocument()
|
||||
await expect.element(screen.getByTestId('mobile-message-composer')).not.toBeVisible()
|
||||
await expect.element(screen.getByTestId('conversation-selector-button')).not.toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('button', { name: 'X', exact: true })).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'Y', exact: true })).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'Scale', exact: true })).toBeVisible()
|
||||
|
||||
const close = screen.getByTestId('view-controls-close-button').element()
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The drawer suppressed focus restoration before emitting the mode change,
|
||||
// but the Stage did not move focus into the newly rendered controls.
|
||||
await expect.element(screen.getByTestId('view-controls-close-button')).toHaveFocus()
|
||||
expect(close.getBoundingClientRect().right).toBe(378)
|
||||
expect(close.getBoundingClientRect().top).toBe(12)
|
||||
const toolbar = screen.getByTestId('view-controls-toolbar').element()
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Fixed horizontal padding ignored display cutouts in landscape viewports.
|
||||
// The toolbar now uses the same left and right safe-area minimum as the header.
|
||||
expect(toolbar.classList).toContain('pl-[max(0.75rem,env(safe-area-inset-left))]')
|
||||
expect(toolbar.classList).toContain('pr-[max(0.75rem,env(safe-area-inset-right))]')
|
||||
|
||||
await screen.getByRole('button', { name: 'stage.mobile-tools.close-view', exact: true }).click()
|
||||
|
||||
await expect.element(screen.getByTestId('mobile-message-composer')).toBeVisible()
|
||||
await expect.element(screen.getByTestId('conversation-selector-button')).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'X', exact: true })).not.toBeInTheDocument()
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Closing view mode removed its focused header without moving focus to the
|
||||
// newly mounted normal header, so keyboard users fell back to the document body.
|
||||
await expect.element(screen.getByTestId('mobile-settings-button')).toHaveFocus()
|
||||
expect(viewControl.viewControlsEnabled.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a docked input bubble mounted while view controls are open', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Entering view mode removed the composer subtree. Its dock animation stores
|
||||
// opacity and position on the mounted elements, while the docked state survives.
|
||||
// Recreating the subtree therefore lost the visual state when view mode closed.
|
||||
await page.viewport(390, 844)
|
||||
const { screen, stageModel } = await renderArea(MobileInteractiveArea)
|
||||
stageModel.setStageModelRenderer('live2d')
|
||||
const bubble = screen.getByTestId('mobile-input-bubble').element()
|
||||
const input = screen.getByRole('textbox').element()
|
||||
const icon = bubble.querySelector<HTMLElement>('[aria-hidden="true"]')!
|
||||
const bounds = bubble.getBoundingClientRect()
|
||||
const pointer = {
|
||||
bubbles: true,
|
||||
clientX: bounds.left + bounds.width / 2,
|
||||
clientY: bounds.top + bounds.height / 2,
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'touch',
|
||||
}
|
||||
vi.spyOn(bubble, 'setPointerCapture').mockImplementation(() => {})
|
||||
|
||||
bubble.dispatchEvent(new PointerEvent('pointerdown', { ...pointer, button: 0, buttons: 1 }))
|
||||
await new Promise(resolve => setTimeout(resolve, 550))
|
||||
bubble.dispatchEvent(new PointerEvent('pointermove', { ...pointer, buttons: 1, clientY: pointer.clientY - 80 }))
|
||||
bubble.dispatchEvent(new PointerEvent('pointerup', { ...pointer, buttons: 0, clientY: pointer.clientY - 80 }))
|
||||
await expect.poll(() => getComputedStyle(input).opacity).toBe('0')
|
||||
expect(getComputedStyle(icon).opacity).toBe('1')
|
||||
|
||||
await screen.getByTestId('mobile-settings-button').click()
|
||||
await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click()
|
||||
|
||||
expect(bubble.isConnected).toBe(true)
|
||||
await expect.element(screen.getByTestId('mobile-message-composer')).not.toBeVisible()
|
||||
|
||||
await screen.getByTestId('view-controls-close-button').click()
|
||||
|
||||
expect(screen.getByTestId('mobile-input-bubble').element()).toBe(bubble)
|
||||
expect(getComputedStyle(input).opacity).toBe('0')
|
||||
expect(getComputedStyle(icon).opacity).toBe('1')
|
||||
})
|
||||
|
||||
it('closes view controls with Escape and restores focus', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Moving view controls out of the dismissible drawer removed its Escape
|
||||
// behavior, leaving keyboard users in the focused Stage mode.
|
||||
await page.viewport(390, 844)
|
||||
const { screen, stageModel } = await renderArea(MobileInteractiveArea)
|
||||
stageModel.setStageModelRenderer('live2d')
|
||||
|
||||
await screen.getByTestId('mobile-settings-button').click()
|
||||
await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click()
|
||||
await expect.element(screen.getByTestId('view-controls-close-button')).toHaveFocus()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
|
||||
await expect.element(screen.getByTestId('mobile-message-composer')).toBeVisible()
|
||||
await expect.element(screen.getByTestId('view-controls-close-button')).not.toBeInTheDocument()
|
||||
await expect.element(screen.getByTestId('mobile-settings-button')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('exits view controls when the active renderer changes', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Mobile view mode combined both renderer flags. After a renderer switch,
|
||||
// the previous flag kept the chat hidden while the new renderer had no controls.
|
||||
// The active renderer now owns the visible mode, and transitions clear stale flags.
|
||||
await page.viewport(390, 844)
|
||||
const live2dViewControl = useL2dViewControl()
|
||||
const threeViewControl = useThreeViewControl()
|
||||
const { screen, stageModel } = await renderArea(MobileInteractiveArea)
|
||||
stageModel.setStageModelRenderer('live2d')
|
||||
|
||||
await screen.getByTestId('mobile-settings-button').click()
|
||||
await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click()
|
||||
await expect.element(screen.getByTestId('view-controls-close-button')).toBeVisible()
|
||||
|
||||
stageModel.setStageModelRenderer('vrm')
|
||||
|
||||
await expect.element(screen.getByTestId('mobile-message-composer')).toBeVisible()
|
||||
await expect.element(screen.getByTestId('view-controls-close-button')).not.toBeInTheDocument()
|
||||
expect(live2dViewControl.viewControlsEnabled.value).toBe(false)
|
||||
expect(threeViewControl.viewControlsEnabled.value).toBe(false)
|
||||
await expect.element(screen.getByTestId('mobile-settings-button')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('shows all five mobile view controls for VRM models', async () => {
|
||||
await page.viewport(390, 844)
|
||||
const { screen, stageModel } = await renderArea(MobileInteractiveArea)
|
||||
stageModel.setStageModelRenderer('vrm')
|
||||
|
||||
await screen.getByTestId('mobile-settings-button').click()
|
||||
await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'X', exact: true })).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'Y', exact: true })).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'Z', exact: true })).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'Dis', exact: true })).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'FOV', exact: true })).toBeVisible()
|
||||
|
||||
await screen.getByTestId('view-controls-close-button').click()
|
||||
})
|
||||
|
||||
it('keeps the empty mobile input compact and aligns the one-line send action', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
|
||||
@@ -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