fix(stage-*): follow-up fix for #1696 (#1809)

This commit is contained in:
DrHuangMHT
2026-05-19 13:54:41 +08:00
committed by GitHub
parent 82b0d6a80c
commit c2bf73c95d
27 changed files with 459 additions and 228 deletions
+1
View File
@@ -69,6 +69,7 @@
"devDependencies": {
"@types/culori": "^4.0.1",
"@types/three": "^0.184.0",
"vitest": "catalog:",
"vue-tsc": "^3.2.6"
}
}
@@ -1,10 +1,31 @@
<script setup lang="ts">
import { RoundRange } from '@proj-airi/ui'
import { onUnmounted } from 'vue'
import { computed, onUnmounted } from 'vue'
import { controlConfig, useL2dViewControl } from '../../stores'
import { defaultControlConfig as conf, formatter, useL2dViewControl } from '../../stores'
const { scale, position, viewControlsEnabled, viewControlMode } = useL2dViewControl()
const { scale, position, viewControlsEnabled, viewControlMode, set: setValue } = useL2dViewControl()
const controlledValue = computed({
get() {
switch (viewControlMode.value) {
case 'x':
return position.value.x
case 'y':
return position.value.y
case 'scale':
return scale.value
default: throw new Error(`Unexpected control key: ${viewControlMode.value}`)
}
},
set(value) {
setValue(viewControlMode.value, value)
},
})
const formattedValue = computed(() => {
return formatter[viewControlMode.value](controlledValue.value)
})
onUnmounted(() => {
viewControlsEnabled.value = false
@@ -15,31 +36,13 @@ onUnmounted(() => {
<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">
<div :key="viewControlMode" 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
v-model="controlledValue" :min="conf[viewControlMode].min" :max="conf[viewControlMode].max" :step="conf[viewControlMode].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) }}
{{ formattedValue }}
</div>
</div>
</Transition>
@@ -62,13 +62,13 @@ export const useLive2d = defineStore('live2d', () => {
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 { position, scale, reset: resetViewControl } = useL2dViewControl()
const { position, scale, set: setViewControl } = useL2dViewControl()
// Live2D model parameters
const modelParameters = useLocalStorageManualReset<Record<string, number>>('settings/live2d/parameters', defaultModelParameters)
function resetState() {
supportedControl.forEach(c => resetViewControl(c))
supportedControl.forEach(c => setViewControl(c))
currentMotion.reset()
availableMotions.reset()
motionMap.reset()
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defaultControlConfig, formatter, supportedControl, useL2dViewControl } from './view-control'
vi.mock('@vueuse/core', async () => {
const vue = await import('vue')
return {
useLocalStorage: vi.fn((_key, initialValue) => vue.ref(initialValue)),
}
})
describe('useL2dViewControl', () => {
let composable: ReturnType<typeof useL2dViewControl>
beforeEach(() => {
composable = useL2dViewControl()
})
it('should initialize with defaults', () => {
expect(composable.viewControlsEnabled.value).toBe(false)
expect(composable.viewControlMode.value).toBe('scale')
expect(composable.position.value).toEqual({ x: 0, y: 0 })
expect(composable.scale.value).toBe(1)
})
describe('set function', () => {
it('should set model position x', () => {
composable.set('x', 5)
expect(composable.position.value.x).toBe(5)
})
it('should set scale', () => {
composable.set('scale', 2)
expect(composable.scale.value).toBe(2)
})
it('should clamp values to max', () => {
supportedControl.forEach(key => composable.set(key, Number.MAX_VALUE))
expect(composable.position.value.x).toBe(defaultControlConfig.x.max)
expect(composable.position.value.y).toBe(defaultControlConfig.y.max)
expect(composable.scale.value).toBe(defaultControlConfig.scale.max)
})
it('should clamp values to min', () => {
supportedControl.forEach(key => composable.set(key, -Number.MAX_VALUE))
expect(composable.position.value.x).toBe(defaultControlConfig.x.min)
expect(composable.position.value.y).toBe(defaultControlConfig.y.min)
expect(composable.scale.value).toBe(defaultControlConfig.scale.min)
})
it('should reset to default when value is omitted', () => {
supportedControl.forEach(k => composable.set(k, defaultControlConfig[k].default + 1))
supportedControl.forEach(k => composable.set(k))
expect(composable.position.value.x).toBe(defaultControlConfig.x.default)
expect(composable.position.value.y).toBe(defaultControlConfig.y.default)
expect(composable.scale.value).toBe(defaultControlConfig.scale.default)
})
})
describe('formatter', () => {
it('should format percentages with 1 decimals', () => {
expect(formatter.x(123.44)).toBe('123.4%')
expect(formatter.x(123.56)).toBe('123.6%')
expect(formatter.y(11.22)).toBe('11.2%')
})
it('should format to percentage with 0 decimal', () => {
expect(formatter.scale(1.234)).toBe('123%')
expect(formatter.scale(1.236)).toBe('124%')
expect(formatter.scale(0.11)).toBe('11%')
})
})
})
@@ -3,60 +3,68 @@ 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 }
interface ControlConfig { min: number, max: number, step: number, default: number, buttonText: 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 */
/** model position relative to the center of the screen, in percentages */
const position = useLocalStorage<{ x: number, y: number }>('settings/live2d/position', { x: 0, y: 0 })
/** model scaling. `1` means no scaling. */
/** model scaling factor. `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
export const defaultControlConfig: Record<SupportedControl, ControlConfig> = {
// TODO: allow user to set preferred default
x: {
min: -500,
max: 500,
step: 0.1,
default: 0,
format: formatPercentD1,
buttonText: 'X',
},
y: {
min: -500,
max: 500,
step: 0.1,
default: 0,
format: formatPercentD1,
buttonText: 'Y',
},
scale: {
min: 0.01,
max: 3,
step: 0.01,
default: 1,
format: formatToPercent,
buttonText: 'Scale',
},
}
export const formatter: Record<SupportedControl, (val: number) => string> = {
x: formatPercentD1,
y: formatPercentD1,
scale: formatToPercent,
}
const clampMinMax = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
export function useL2dViewControl() {
/**
* reset the given control to its default value.
* @param key the control to reset
* @param value optional, will reset the value to its default if not provided
*/
function reset(key: SupportedControl) {
function set(key: SupportedControl, value?: number) {
const clamped = value !== undefined ? clampMinMax(value, defaultControlConfig[key].min, defaultControlConfig[key].max) : undefined
switch (key) {
case 'x':
position.value.x = controlConfig.x.default
position.value.x = clamped ?? defaultControlConfig.x.default
break
case 'y':
position.value.y = controlConfig.y.default
position.value.y = clamped ?? defaultControlConfig.y.default
break
case 'scale':
scale.value = controlConfig.scale.default
scale.value = clamped ?? defaultControlConfig.scale.default
break
}
}
@@ -67,7 +75,7 @@ export function useL2dViewControl() {
/** model scaling in percentages. `100` means no scaling. */
scale,
/** reset the given control to its default value. */
reset,
set,
/** show or hide the control element(slider) on stage */
viewControlsEnabled,
/** what value to control for the control element */
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
})