From 0fd71bc1abed585955c1df18a40b95ef3b5276bf Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Mon, 7 Sep 2026 12:37:45 +0800 Subject: [PATCH] feat(stage-layouts): add mobile view adjustment mode (#2475) --- .../InteractiveArea.browser.test.ts | 171 ++++++++++++++++++ packages/i18n/src/locales/en/stage.yaml | 1 + packages/i18n/src/locales/zh-Hans/stage.yaml | 3 +- .../InteractiveArea/Actions/ViewControls.vue | 84 ++++++--- .../Layouts/MobileInteractiveArea.vue | 105 ++++++++++- .../Layouts/mobile-settings-drawer.vue | 27 ++- 6 files changed, 348 insertions(+), 43 deletions(-) diff --git a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts index 2b00d177f..913685e1c 100644 --- a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts +++ b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.browser.test.ts @@ -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('.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('[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: // diff --git a/packages/i18n/src/locales/en/stage.yaml b/packages/i18n/src/locales/en/stage.yaml index 865d59eec..49c9bb6a7 100644 --- a/packages/i18n/src/locales/en/stage.yaml +++ b/packages/i18n/src/locales/en/stage.yaml @@ -17,6 +17,7 @@ mobile-tools: settings: All settings about: About view: View + close-view: Save and close view controls hearing: Hearing chat: actions: diff --git a/packages/i18n/src/locales/zh-Hans/stage.yaml b/packages/i18n/src/locales/zh-Hans/stage.yaml index 59c4ba9fa..016e20b1f 100644 --- a/packages/i18n/src/locales/zh-Hans/stage.yaml +++ b/packages/i18n/src/locales/zh-Hans/stage.yaml @@ -16,7 +16,8 @@ mobile-tools: background: 背景 settings: 全部设置 about: 关于 - view: 视角 + view: 视图 + close-view: 保存并关闭视图调节 hearing: 听觉 chat: actions: diff --git a/packages/stage-layouts/src/components/Layouts/InteractiveArea/Actions/ViewControls.vue b/packages/stage-layouts/src/components/Layouts/InteractiveArea/Actions/ViewControls.vue index a4d6357d9..d5bae2386 100644 --- a/packages/stage-layouts/src/components/Layouts/InteractiveArea/Actions/ViewControls.vue +++ b/packages/stage-layouts/src/components/Layouts/InteractiveArea/Actions/ViewControls.vue @@ -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 + } }