feat(stage-*): dramatically improved mobile

This commit is contained in:
Neko Ayaka
2025-07-21 01:52:48 +08:00
parent f041ec0ac0
commit 0db4fa3f7c
31 changed files with 1016 additions and 593 deletions
@@ -331,6 +331,7 @@ watch(() => messages.value.length, () => {
.line-clamp-2 {
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
@@ -6,7 +6,7 @@ import WhisperWorker from '@proj-airi/stage-ui/libs/workers/worker?worker&url'
import { toWAVBase64 } from '@proj-airi/audio'
import { useMicVAD, useWhisper } from '@proj-airi/stage-ui/composables'
import { useAudioContext, useChatStore, useConsciousnessStore, useProvidersStore, useSettings } from '@proj-airi/stage-ui/stores'
import { BasicTextarea, TransitionVertical } from '@proj-airi/ui'
import { BasicTextarea } from '@proj-airi/ui'
import { useDevicesList } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
@@ -24,8 +24,8 @@ const providersStore = useProvidersStore()
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
const { themeColorsHueDynamic } = storeToRefs(useSettings())
const { audioInputs, ensurePermissions } = useDevicesList({ constraints: { audio: true } })
const { selectedAudioDevice, isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { ensurePermissions } = useDevicesList({ constraints: { audio: true } })
const { isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { send, onAfterSend, discoverToolsCompatibility } = useChatStore()
const { messages } = storeToRefs(useChatStore())
const { audioContext } = useAudioContext()
@@ -104,17 +104,6 @@ async function handleTranscription(buffer: ArrayBufferLike) {
generate({ type: 'generate', data: { audio: audioBase64, language: 'en' } })
}
async function handleAudioInputChange(event: Event) {
const target = event.target as HTMLSelectElement
const found = audioInputs.value.find(d => d.deviceId === target.value)
if (!found) {
selectedAudioDevice.value = undefined
return
}
selectedAudioDevice.value = found
}
watch(isAudioInputOn, async (value) => {
if (value === 'false') {
destroy()
@@ -195,46 +184,5 @@ onAfterSend(async () => {
</div>
</div>
</div>
<div flex="~ row" gap-2 hidden>
<div flex="~ row" relative text-white font-normal>
<TransitionVertical>
<fieldset
v-if="showMicrophoneSelect"
right="0" bottom="[calc(100%+8px)]" text="cyan-400 dark:white" bg="white dark:cyan-900"
absolute z-30 h-fit rounded-2xl py-3 pl-3 pr-4 text-right text-nowrap text-sm font-sans
>
<label v-for="(input, index) in audioInputs" :key="index" class="[&_div_span]:dark:hover:bg-cyan-300 [&_div_span]:dark:hover:bg-cyan-900">
<input type="radio" name="audioInput" :value="input.deviceId" hidden @change="handleAudioInputChange">
<div flex="~ row" cursor-pointer items-center grid="cols-2">
<div min-w="6">
<div v-if="input.deviceId === selectedAudioDeviceId" i-solar:check-circle-line-duotone />
</div>
<span
inline-block
:class="[input.deviceId === selectedAudioDeviceId ? 'cyan-400 dark:text-white' : 'cyan-400/50 dark:text-white/50']"
transition="all duration-250 ease-in-out"
>
{{ input.label }}
</span>
</div>
</label>
</fieldset>
</TransitionVertical>
<label
bg="complementary-100 hover:complementary-200 dark:complementary-800 dark:hover:complementary-700"
transition="all duration-250 ease-in-out"
:class="{ 'transition-colors-none': themeColorsHueDynamic }"
text="complementary-400"
h-fit flex cursor-pointer items-center justify-center gap-2 rounded-full px-4 py-2
>
<input v-model="showMicrophoneSelect" type="checkbox" hidden>
<div i-solar:microphone-2-bold-duotone />
<div>
<span v-if="!listening">{{ t('settings.microphone') }}</span>
<span v-else>Listening...</span>
</div>
</label>
</div>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<script lang="ts" setup>
import { Button } from '@proj-airi/stage-ui/components'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
const emits = defineEmits<{
(e: 'reset'): void
}>()
const { stageView, 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')
return
}
mode.value = targetMode
}
</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')">
X
</Button>
<Button variant="secondary-muted" :toggled="mode === 'y'" w-full @click="handleViewControlsToggle('y')">
Y
</Button>
<Button v-if="stageView === '3d'" variant="secondary-muted" :toggled="mode === 'z'" w-full @click="handleViewControlsToggle('z')">
Z
</Button>
<Button variant="secondary-muted" :toggled="mode === 'scale'" w-full @click="handleViewControlsToggle('scale')">
Scale
</Button>
</div>
</Transition>
<button
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"
@click="stageViewControlsEnabled = !stageViewControlsEnabled"
>
<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 />
</Transition>
</button>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
}
</style>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import HeaderLink from './HeaderLink.vue'
</script>
<template>
<header mb-1 w-full gap-2>
<div w-full flex translate-x--2 justify-center>
<HeaderLink />
</div>
</header>
</template>
@@ -4,11 +4,20 @@ import type { ChatProvider } from '@xsai-ext/shared-providers'
import { useMicVAD } from '@proj-airi/stage-ui/composables'
import { useChatStore, useConsciousnessStore, useProvidersStore, useSettings } from '@proj-airi/stage-ui/stores'
import { BasicTextarea } from '@proj-airi/ui'
import { useDark } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { onMounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import MobileChatHistory from '../Widgets/MobileChatHistory.vue'
import ActionViewControls from './InteractiveArea/Actions/ViewControls.vue'
import ViewControlInputs from './ViewControls/Inputs.vue'
const isDark = useDark({ disableTransition: false })
const viewControlsActiveMode = ref<'x' | 'y' | 'z' | 'scale'>('scale')
const viewControlsInputsRef = useTemplateRef<InstanceType<typeof ViewControlInputs>>('viewControlsInputs')
const messageInput = ref('')
const listening = ref(false)
@@ -19,7 +28,7 @@ const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
// const { audioInputs } = useDevicesList({ constraints: { audio: true }, requestPermissions: true })
// const { selectedAudioDevice, isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { isAudioInputOn, selectedAudioDeviceId, themeColorsHueDynamic } = storeToRefs(useSettings())
const { isAudioInputOn, selectedAudioDeviceId, themeColorsHueDynamic, stageView, stageViewControlsEnabled } = storeToRefs(useSettings())
const { send, onAfterSend, discoverToolsCompatibility } = useChatStore()
const { messages } = storeToRefs(useChatStore())
const { t } = useI18n()
@@ -100,34 +109,75 @@ onMounted(() => {
</script>
<template>
<div fixed bottom-0 w-full flex gap-1 max-w="[calc(100dvw-1rem)]">
<MobileChatHistory absolute left-0 top-0 transform="translate-y-[-100%]" h="80dvh" w-full />
<div flex flex-1 gap-1>
<BasicTextarea
v-model="messageInput"
:placeholder="t('stage.message')"
border="solid 2 primary-50 dark:primary-950/10"
text="primary-500 hover:primary-600 dark:primary-100 dark:hover:primary-200 placeholder:primary-400 placeholder:hover:primary-500 placeholder:dark:primary-300 placeholder:dark:hover:primary-400"
bg="primary-50/80 dark:primary-950/80"
max-h="[10lh]" min-h="[calc(1lh+4px+4px)]"
w-full resize-none overflow-y-scroll rounded="[1lh]" px-4 py-0.5 outline-none backdrop-blur-md scrollbar-none
transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
:class="[themeColorsHueDynamic ? 'transition-colors-none placeholder:transition-colors-none' : '']"
default-height="1lh"
@submit="handleSend"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
/>
<button
<div fixed bottom-0 w-full flex flex-col>
<KeepAlive>
<Transition name="fade">
<MobileChatHistory v-if="!stageViewControlsEnabled" max-w="[calc(100%-3.5rem)]" w-full self-start pl-2 />
</Transition>
</KeepAlive>
<div relative w-full self-end>
<div fixed top="50%" translate-y="[-50%]" px-2>
<ViewControlInputs ref="viewControlsInputs" :mode="viewControlsActiveMode" />
</div>
<div translate-y="[-100%]" absolute right-0 w-full px-2 pb-2 font-sans>
<div flex="~ col" w-full gap-1>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="About">
<div i-solar:info-circle-outline size-5 text="neutral-500 dark:neutral-400" />
</button>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Theme" @click="isDark = !isDark">
<Transition name="fade" mode="out-in">
<div v-if="isDark" i-solar:moon-outline size-5 text="neutral-500 dark:neutral-400" />
<div v-else i-solar:sun-2-outline size-5 text="neutral-500 dark:neutral-400" />
</Transition>
</button>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Language">
<div i-solar:earth-outline size-5 text="neutral-500 dark:neutral-400" />
</button>
<RouterLink to="/settings" border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Settings">
<div i-solar:settings-outline size-5 text="neutral-500 dark:neutral-400" />
</RouterLink>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Model">
<div i-solar:face-scan-circle-outline size-5 text="neutral-500 dark:neutral-400" />
</button>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Stage View" @click="stageView = stageView === '2d' ? '3d' : '2d'">
<div v-if="stageView === '2d'" size-5 text="neutral-500 dark:neutral-400" inline-flex items-center justify-center>
<span>2D</span>
</div>
<div v-if="stageView === '3d'" size-5 text="neutral-500 dark:neutral-400" inline-flex items-center justify-center>
<span>3D</span>
</div>
</button>
<ActionViewControls v-model="viewControlsActiveMode" @reset="() => viewControlsInputsRef?.resetOnMode()" />
</div>
</div>
<div bg="white dark:neutral-800" w-full flex gap-1 px-2 py-2>
<BasicTextarea
v-model="messageInput"
:placeholder="t('stage.message')"
border="solid 2 primary-50 dark:primary-950/10"
text="neutral-500 hover:neutral-600 dark:neutral-100 dark:hover:neutral-200 placeholder:neutral-400 placeholder:hover:neutral-500 placeholder:dark:neutral-300 placeholder:dark:hover:neutral-400"
bg="neutral-100/80 dark:neutral-950/80"
max-h="[10lh]" min-h="[calc(1lh+4px+4px)]"
w-full resize-none overflow-y-scroll rounded="[1lh]" px-4 py-0.5 outline-none backdrop-blur-md scrollbar-none
transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
:class="[themeColorsHueDynamic ? 'transition-colors-none placeholder:transition-colors-none' : '']"
default-height="1lh"
@submit="handleSend"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
/>
<button
v-if="messageInput.trim() || isComposing"
w="[calc(1lh+4px+4px)]" h="[calc(1lh+4px+4px)]" aspect-square flex items-center self-end justify-center rounded-full outline-none backdrop-blur-md
text="primary-500 hover:primary-600 dark:primary-100 dark:hover:primary-200 placeholder:primary-400 placeholder:hover:primary-500 placeholder:dark:primary-300 placeholder:dark:hover:primary-400"
bg="primary-50/80 dark:primary-950/80"
@click="handleSend"
>
<div i-solar:arrow-up-outline />
</button>
v-if="messageInput.trim() || isComposing"
w="[calc(1lh+4px+4px)]" h="[calc(1lh+4px+4px)]" aspect-square flex items-center self-end justify-center rounded-full outline-none backdrop-blur-md
text="neutral-500 hover:neutral-600 dark:neutral-900 dark:hover:neutral-800"
bg="primary-50/80 dark:neutral-100/80 hover:neutral-50"
transition="all duration-250 ease-in-out"
@click="handleSend"
>
<div i-solar:arrow-up-outline />
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,203 @@
<script setup lang="ts">
import { useLive2d, useSettings, useVRM } from '@proj-airi/stage-ui/stores'
import { RoundRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const props = defineProps<{
mode: 'x' | 'y' | 'z' | 'scale'
}>()
const { stageView, stageViewControlsEnabled } = storeToRefs(useSettings())
const { scale: vrmScale, position: vrmPosition, modelSize: vrmModelSize } = storeToRefs(useVRM())
const { scale: live2dScale, position: live2dPosition } = storeToRefs(useLive2d())
const viewControlsValueX = computed({
get: () => {
switch (stageView.value) {
case '2d':
return live2dPosition.value.x
case '3d':
return vrmPosition.value.x
default:
return 0
}
},
set: (value) => {
switch (stageView.value) {
case '2d':
live2dPosition.value.x = value
break
case '3d':
vrmPosition.value.x = value
break
default:
break
}
},
})
const viewControlsValueXMin = computed(() => {
return stageView.value === '2d' ? -500 : -vrmModelSize.value.x - 10
})
const viewControlsValueXMax = computed(() => {
return stageView.value === '2d' ? 500 : vrmModelSize.value.x + 10
})
const viewControlsValueY = computed({
get: () => {
switch (stageView.value) {
case '2d':
return live2dPosition.value.y
case '3d':
return vrmPosition.value.y
default:
return 0
}
},
set: (value) => {
switch (stageView.value) {
case '2d':
live2dPosition.value.y = value
break
case '3d':
vrmPosition.value.y = value
break
default:
break
}
},
})
const viewControlsValueYMin = computed(() => {
return stageView.value === '2d' ? -500 : -vrmModelSize.value.y - 10
})
const viewControlsValueYMax = computed(() => {
return stageView.value === '2d' ? 500 : vrmModelSize.value.y + 10
})
const viewControlsValueZ = computed({
get: () => {
switch (stageView.value) {
case '2d':
return 0
case '3d':
return vrmPosition.value.z
default:
return 0
}
},
set: (value) => {
switch (stageView.value) {
case '2d':
break
case '3d':
vrmPosition.value.z = value
break
default:
break
}
},
})
const viewControlsValueZMin = computed(() => {
return stageView.value === '2d' ? -500 : -vrmModelSize.value.z - 10
})
const viewControlsValueZMax = computed(() => {
return stageView.value === '2d' ? 500 : vrmModelSize.value.z + 10
})
const viewControlsValueScale = computed({
get: () => {
if (stageView.value === '2d') {
return live2dScale.value
}
return vrmScale.value
},
set: (value) => {
if (stageView.value === '2d') {
live2dScale.value = value
}
else {
vrmScale.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">
<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" 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" 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="stageView === '3d' && props.mode === 'z'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange v-model="viewControlsValueZ" :min="viewControlsValueZMin" :max="viewControlsValueZMax" :step="0.01" 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" 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>
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { useMarkdown } from '@proj-airi/stage-ui/composables'
import { useChatStore } from '@proj-airi/stage-ui/stores'
import { useElementBounding, useScroll } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -10,8 +9,6 @@ const chatHistoryRef = ref<HTMLDivElement>()
const { t } = useI18n()
const { messages, sending } = storeToRefs(useChatStore())
const bounding = useElementBounding(chatHistoryRef, { immediate: true, windowScroll: true, windowResize: true })
const { y: chatHistoryContainerY } = useScroll(chatHistoryRef)
const { process } = useMarkdown()
const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
@@ -19,16 +16,20 @@ const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
onBeforeMessageComposed(async () => {
// Scroll down to the new sent message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
if (!chatHistoryRef.value)
return
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
})
})
onTokenLiteral(async () => {
// Scroll down to the new responding message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
if (!chatHistoryRef.value)
return
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
})
})
</script>
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { useMarkdown } from '@proj-airi/stage-ui/composables'
import { useChatStore } from '@proj-airi/stage-ui/stores'
import { useElementBounding, useScroll } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -10,8 +9,6 @@ const chatHistoryRef = ref<HTMLDivElement>()
const { t } = useI18n()
const { messages } = storeToRefs(useChatStore())
const bounding = useElementBounding(chatHistoryRef, { immediate: true, windowScroll: true, windowResize: true })
const { y: chatHistoryContainerY } = useScroll(chatHistoryRef)
const { process } = useMarkdown()
const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
@@ -19,25 +16,27 @@ const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
onBeforeMessageComposed(async () => {
// Scroll down to the new sent message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
if (!chatHistoryRef.value)
return
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
})
})
onTokenLiteral(async () => {
// Scroll down to the new responding message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
if (!chatHistoryRef.value)
return
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
})
})
</script>
<template>
<div py="1" flex="~ col" rounded="lg" overflow-hidden>
<div flex-1 /> <!-- spacer -->
<div ref="chatHistoryRef" v-auto-animate h-full w-full max-h="40%" flex="~ col" overflow-scroll class="chat-history">
<div flex-1 /> <!-- spacer -->
<div flex="~ col" rounded="lg" overflow-hidden>
<div ref="chatHistoryRef" v-auto-animate max-h="35dvh" z-5 flex="~ col" h-full w-full overflow-scroll class="chat-history">
<div v-for="(message, index) in messages" :key="index" mb-2>
<div v-if="message.role === 'error'" flex mr="12">
<div
+1 -1
View File
@@ -138,7 +138,7 @@ onMounted(() => updateThemeColor())
<HeaderLink />
</div>
<!-- Content -->
<div class="h-full px-3 py-0 sm:h-[calc(100%-4rem)] md:py-0 xl:px-0" flex="~ col" mx-auto max-w-screen-xl>
<div class="h-[calc(100%-40px)] px-3 py-0 2xl:max-w-[2160px] sm:h-[calc(100%-56px)] md:py-0 xl:px-4" flex="~ col" mx-auto>
<PageHeader
:title="routeHeaderMetadata?.title"
:subtitle="routeHeaderMetadata?.subtitle"
+5 -3
View File
@@ -8,6 +8,7 @@ import { onMounted, ref, watch } from 'vue'
import Cross from '../components/Backgrounds/Cross.vue'
import Header from '../components/Layouts/Header.vue'
import InteractiveArea from '../components/Layouts/InteractiveArea.vue'
import MobileHeader from '../components/Layouts/MobileHeader.vue'
import MobileInteractiveArea from '../components/Layouts/MobileInteractiveArea.vue'
import AnimatedWave from '../components/Widgets/AnimatedWave.vue'
@@ -41,7 +42,8 @@ onMounted(() => updateThemeColor())
<div relative flex="~ col" z-2 h-100dvh w-100vw of-hidden>
<!-- header -->
<div class="px-0 py-1 md:px-3 md:py-3" w-full gap-2>
<Header class="flex" />
<Header class="hidden md:flex" />
<MobileHeader class="flex md:hidden" />
</div>
<!-- page -->
<div relative flex="~ 1 row gap-y-0 gap-x-2 <md:col">
@@ -56,8 +58,8 @@ onMounted(() => updateThemeColor())
:y-offset="positionInPercentageString.y"
:scale="scale"
/>
<InteractiveArea class="flex <md:hidden" absolute h="85dvh" right-4 flex-1 max-w="500px" min-w="30%" />
<MobileInteractiveArea class="hidden <md:block" mx2 mb2 @settings-open="handleSettingsOpen" />
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
<MobileInteractiveArea v-if="isMobile" @settings-open="handleSettingsOpen" />
</div>
</div>
</AnimatedWave>
@@ -43,8 +43,14 @@ const {
</script>
<template>
<div flex class="relative h-[calc(100dvh-8rem)] flex-col-reverse md:flex-row">
<ModelSettings live-2d-settings-class="w-100% md:w-40% lg:w-30% xl:w-30% 2xl:w-30% h-fit overflow-y-scroll absolute bottom-0 right-0 top-0" :palette="palette" @extract-colors-from-model="extractColorsFromModel" />
<div flex class="relative h-full flex-col-reverse md:flex-row">
<ModelSettings
live-2d-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
live-2d-settings-class="w-100% md:w-40% lg:w-30% xl:w-30% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
vrm-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
vrm-settings-class="w-100% md:w-40% lg:w-30% xl:w-30% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
:palette="palette" @extract-colors-from-model="extractColorsFromModel"
/>
</div>
<IconAnimation
@@ -58,6 +64,7 @@ const {
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
/>
<div
v-else
v-motion
+24 -7
View File
@@ -1,14 +1,19 @@
<script setup lang="ts">
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useDark, useMouse } from '@vueuse/core'
import { ref } from 'vue'
import { useLive2d } from '@proj-airi/stage-ui/stores'
import { breakpointsTailwind, useBreakpoints, useDark, useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import Cross from '../../components/Backgrounds/Cross.vue'
import Header from '../../components/Layouts/Header.vue'
import InteractiveArea from '../../components/Layouts/InteractiveArea.vue'
import MobileHeader from '../../components/Layouts/MobileHeader.vue'
import MobileInteractiveArea from '../../components/Layouts/MobileInteractiveArea.vue'
import AnimatedWave from '../../components/Widgets/AnimatedWave.vue'
import { themeColorFromPropertyOf, useThemeColor } from '../../composables/theme-color'
const dark = useDark()
const paused = ref(false)
@@ -17,19 +22,28 @@ function handleSettingsOpen(open: boolean) {
}
const positionCursor = useMouse()
const { scale, position, positionInPercentageString } = storeToRefs(useLive2d())
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
const { updateThemeColor } = useThemeColor(themeColorFromPropertyOf('.widgets.top-widgets .colored-area', 'background-color'))
watch(dark, () => updateThemeColor(), { immediate: true })
onMounted(() => updateThemeColor())
</script>
<template>
<Cross>
<AnimatedWave
class="widgets top-widgets"
:fill-color="dark
? 'oklch(35% calc(var(--chromatic-chroma) * 0.6) var(--chromatic-hue))'
: 'color-mix(in srgb, oklch(95% calc(var(--chromatic-chroma-50) * 0.5) var(--chromatic-hue)) 80%, oklch(100% 0 360))'"
>
<div relative flex="~ col" z-2 h-100dvh w-100vw of-hidden>
<!-- header -->
<div>
<Header class="flex" p2 />
<div class="px-0 py-1 md:px-3 md:py-3" w-full gap-2>
<Header class="hidden md:flex" />
<MobileHeader class="flex md:hidden" />
</div>
<!-- page -->
<div relative flex="~ 1 row gap-y-0 gap-x-2 <md:col">
@@ -40,9 +54,12 @@ const positionCursor = useMouse()
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="`${isMobile ? position.x : position.x - 10}%`"
:y-offset="positionInPercentageString.y"
:scale="scale"
/>
<InteractiveArea class="flex <md:hidden" flex-1 max-w="500px" min-w="30%" />
<MobileInteractiveArea class="hidden <md:block" mx2 mb2 @settings-open="handleSettingsOpen" />
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
<MobileInteractiveArea v-if="isMobile" @settings-open="handleSettingsOpen" />
</div>
</div>
</AnimatedWave>
@@ -50,7 +67,7 @@ const positionCursor = useMouse()
</template>
<route lang="yaml">
name: StageScenePage
name: IndexScenePage
meta:
layout: stage
stageTransition:
+1
View File
@@ -1,4 +1,5 @@
@import './transitions.css';
@import './vue-transitions.css';
:root {
--bg-color-light: rgb(255 255 255);
@@ -0,0 +1,48 @@
.fade-slide-out-r-to-l-enter-active,
.fade-slide-out-r-to-l-leave-active {
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
}
.fade-slide-out-r-to-l-enter-from,
.fade-slide-out-r-to-l-leave-to {
opacity: 0;
transform: translateX(10px);
}
.fade-slide-out-r-to-l-enter-to,
.fade-slide-out-r-to-l-leave-from {
opacity: 1;
transform: translateX(0);
}
.fade-slide-out-l-to-r-enter-active,
.fade-slide-out-l-to-r-leave-active {
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
}
.fade-slide-out-l-to-r-enter-from,
.fade-slide-out-l-to-r-leave-to {
opacity: 0;
transform: translateX(-10px);
}
.fade-slide-out-l-to-r-enter-to,
.fade-slide-out-l-to-r-leave-from {
opacity: 1;
transform: translateX(0);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
}
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { RoundRange } from '@proj-airi/ui'
import { ref } from 'vue'
const minValue = ref(0)
const midValue = ref(50)
const maxValue = ref(100)
</script>
<template>
<Story title="Round Range" group="form" :layout="{ type: 'grid', width: '100%' }">
<template #controls>
<ThemeColorsHueControl />
</template>
<Variant id="default-min" title="Default (min)">
<RoundRange v-model="minValue" w-full />
</Variant>
<Variant id="default-mid" title="Default (mid)">
<RoundRange v-model="midValue" w-full />
</Variant>
<Variant id="default-max" title="Default (max)">
<RoundRange v-model="maxValue" w-full />
</Variant>
</Story>
</template>
@@ -14,7 +14,7 @@ const themeClasses: Record<ThemeVariant, {
}> = {
primary: {
container: [
'bg-primary-400/10 dark:bg-primary-50/10 text-neutral-900/80 backdrop-blur-md',
'bg-primary-100/60 dark:bg-primary-50/10 text-neutral-900/80 backdrop-blur-md',
`before:bg-primary-400 dark:bg-primary-900/10 dark:text-neutral-100/80 before:content-[''] before:dark:bg-primary-900`,
],
label: [
@@ -23,7 +23,7 @@ const themeClasses: Record<ThemeVariant, {
},
lime: {
container: [
'bg-lime-400/10 dark:bg-lime-50/10 text-neutral-900/80 backdrop-blur-md',
'bg-lime-100/60 dark:bg-lime-50/10 text-neutral-900/80 backdrop-blur-md',
`before:bg-lime-400 dark:bg-lime-900/10 dark:text-neutral-100/80 before:content-[''] before:dark:bg-lime-900`,
],
label: [
@@ -32,7 +32,7 @@ const themeClasses: Record<ThemeVariant, {
},
violet: {
container: [
'bg-violet-400/10 dark:bg-violet-50/10 text-neutral-900/80 backdrop-blur-md',
'bg-violet-100/60 dark:bg-violet-50/10 text-neutral-900/80 backdrop-blur-md',
`before:bg-violet-400 dark:bg-violet-900/10 dark:text-neutral-100/80 before:content-[''] before:dark:bg-violet-900`,
],
label: [
@@ -41,7 +41,7 @@ const themeClasses: Record<ThemeVariant, {
},
orange: {
container: [
'bg-orange-400/10 dark:bg-orange-50/10 text-neutral-900/80 backdrop-blur-md',
'bg-orange-100/60 dark:bg-orange-50/10 text-neutral-900/80 backdrop-blur-md',
`before:bg-orange-400 dark:bg-orange-900/10 dark:text-neutral-100/80 before:content-[''] before:dark:bg-orange-900`,
],
label: [
@@ -4,7 +4,7 @@ import { computed } from 'vue'
import { BidirectionalTransition } from '../Misc'
// Define button variants for better type safety and maintainability
type ButtonVariant = 'primary' | 'secondary' | 'danger'
type ButtonVariant = 'primary' | 'secondary' | 'secondary-muted' | 'danger'
type ButtonTheme
= | 'default'
@@ -15,6 +15,7 @@ type ButtonTheme
type ButtonSize = 'sm' | 'md' | 'lg'
interface ButtonProps {
toggled?: boolean // Optional toggled state for toggle buttons
icon?: string // Icon class name
label?: string // Button text label
disabled?: boolean // Disabled state
@@ -26,6 +27,7 @@ interface ButtonProps {
}
const props = withDefaults(defineProps<ButtonProps>(), {
toggled: false,
variant: 'primary',
disabled: false,
loading: false,
@@ -38,18 +40,33 @@ const isDisabled = computed(() => props.disabled || props.loading)
// Extract variant styles for better organization
const variantClasses: Record<ButtonVariant, {
default: string
// dimmed: string
// lightened: string
default: {
default: string
nonToggled?: string
toggled?: string
}
}> = {
primary: {
default: 'bg-primary-500/15 hover:bg-primary-500/20 active:bg-primary-500/30 dark:bg-primary-700/30 dark:hover:bg-primary-700/40 dark:active:bg-primary-700/30 focus:ring-primary-300/60 dark:focus:ring-primary-600/30 border-2 border-solid border-primary-500/5 dark:border-primary-900/40 text-primary-950 dark:text-primary-100',
'primary': {
default: {
default: 'bg-primary-500/15 hover:bg-primary-500/20 active:bg-primary-500/30 dark:bg-primary-700/30 dark:hover:bg-primary-700/40 dark:active:bg-primary-700/30 focus:ring-primary-300/60 dark:focus:ring-primary-600/30 border-2 border-solid border-primary-500/5 dark:border-primary-900/40 text-primary-950 dark:text-primary-100',
},
},
secondary: {
default: 'bg-neutral-400/15 hover:bg-neutral-400/20 active:bg-neutral-400/30 dark:bg-neutral-700/60 dark:hover:bg-neutral-700/80 dark:active:bg-neutral-700/60 focus:ring-neutral-300/30 dark:focus:ring-neutral-600/60 dark:focus:ring-neutral-600/30 border-2 border-solid border-neutral-300/30 dark:border-neutral-700/30 text-neutral-950 dark:text-neutral-100',
'secondary': {
default: {
default: 'bg-neutral-100/55 hover:bg-neutral-400/20 active:bg-neutral-400/30 dark:bg-neutral-700/60 dark:hover:bg-neutral-700/80 dark:active:bg-neutral-700/60 focus:ring-neutral-300/30 dark:focus:ring-neutral-600/60 dark:focus:ring-neutral-600/30 border-2 border-solid border-neutral-300/30 dark:border-neutral-700/30 text-neutral-950 dark:text-neutral-100',
},
},
danger: {
default: 'bg-red-500/15 hover:bg-red-500/20 active:bg-red-500/30 dark:bg-red-700/30 dark:hover:bg-red-700/40 dark:active:bg-red-700/30 focus:ring-red-300/30 dark:focus:ring-red-600/60 dark:focus:ring-red-600/30 border-2 border-solid border-red-200/30 dark:border-red-900/30 text-red-950 dark:text-red-100',
'secondary-muted': {
default: {
default: 'hover:bg-neutral-50/50 active:bg-neutral-50/90 hover:dark:bg-neutral-800/50 active:dark:bg-neutral-800/90 border-2 border-solid border-neutral-100/60 dark:border-neutral-800/30 focus:ring-neutral-300/30 dark:focus:ring-neutral-600/60 dark:focus:ring-neutral-600/30',
nonToggled: 'bg-neutral-50/70 dark:bg-neutral-800/70 text-neutral-500 dark:text-neutral-400',
toggled: 'bg-white/90 dark:bg-neutral-500/70 ring-neutral-300/30 dark:ring-neutral-600/60 ring-2 dark:ring-neutral-600/30 text-primary-500 dark:text-primary-100',
},
},
'danger': {
default: {
default: 'bg-red-500/15 hover:bg-red-500/20 active:bg-red-500/30 dark:bg-red-700/30 dark:hover:bg-red-700/40 dark:active:bg-red-700/30 focus:ring-red-300/30 dark:focus:ring-red-600/60 dark:focus:ring-red-600/30 border-2 border-solid border-red-200/30 dark:border-red-900/30 text-red-950 dark:text-red-100',
},
},
}
@@ -62,14 +79,16 @@ const sizeClasses: Record<ButtonSize, string> = {
// Base classes that are always applied
const baseClasses = computed(() => [
'rounded-lg font-medium outline-none',
'rounded-xl font-medium outline-none',
'transition-all duration-200 ease-in-out',
'disabled:cursor-not-allowed disabled:opacity-50',
'backdrop-blur-md',
props.block ? 'w-full' : '',
sizeClasses[props.size],
variantClasses[props.variant][props.theme],
variantClasses[props.variant][props.theme].default,
props.toggled ? variantClasses[props.variant][props.theme].toggled || '' : variantClasses[props.variant][props.theme].nonToggled || '',
{ 'opacity-50 cursor-not-allowed': isDisabled.value },
'focus:ring-2 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-neutral-900',
'focus:ring-2',
])
</script>
@@ -1,42 +0,0 @@
<script setup lang="ts">
import { useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useLive2d } from '../../../../stores'
import { Screen } from '../../../Misc'
import { Live2DCanvas, Live2DModel } from '../../../Scenes'
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const { positionInPercentageString, scale } = storeToRefs(useLive2d())
const positionCursor = useMouse()
</script>
<template>
<Screen v-slot="{ width, height }" relative min-h-70dvh>
<Live2DCanvas
ref="live2dCanvasRef"
v-slot="{ app }"
:width="width"
:height="height"
:resolution="3"
rounded-full
>
<Live2DModel
:app="app"
:mouth-open-size="0"
:width="width"
:height="height"
:paused="false"
:focus-at="{
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="positionInPercentageString.x"
:y-offset="positionInPercentageString.y"
:scale="scale"
/>
</Live2DCanvas>
</Screen>
</template>
@@ -31,6 +31,7 @@ const {
loadSource,
loadingModel,
modelUrl,
scale,
modelSize,
modelOffset,
} = storeToRefs(vrm)
@@ -59,6 +60,122 @@ modelFileDialog.onChange((files) => {
{{ t('settings.vrm.switch-to-vrm.change-to-vrm') }}
</Button>
</Section>
<Section
:title="t('settings.vrm.scale-and-position.title')"
icon="i-solar:scale-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<Callout
:label="t('settings.vrm.scale-and-position.model-info-title')"
w-full
>
<div>
<div class="text-sm text-neutral-600 space-y-1">
<div class="flex justify-between">
<span>{{ t('settings.vrm.scale-and-position.model-info-x') }}</span>
<span>{{ modelSize.x.toFixed(4) }}</span>
</div>
<div class="flex justify-between">
<span>{{ t('settings.vrm.scale-and-position.model-info-y') }}</span>
<span>{{ modelSize.y.toFixed(4) }}</span>
</div>
<div class="flex justify-between">
<span>{{ t('settings.vrm.scale-and-position.model-info-z') }}</span>
<span>{{ modelSize.z.toFixed(4) }}</span>
</div>
</div>
</div>
</Callout>
<Callout
theme="lime"
label="Tips!"
w-full
>
<div class="text-sm text-neutral-600 space-y-1">
{{ t('settings.vrm.scale-and-position.tips') }}
</div>
</Callout>
<FieldRange
v-model="scale"
as="div"
:min="0"
:max="5"
:step="scale / 100"
:label="t('settings.vrm.scale-and-position.x')"
:format-value="val => val.toFixed(4)"
>
<template #label>
<div flex items-center>
<div>
{{ t('settings.vrm.scale-and-position.scale') }}
</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => scale = 1">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange
v-model="modelOffset.x"
as="div"
:min="-modelSize.x - 10"
:max="modelSize.x + 10"
:step="modelSize.x / 100"
:label="t('settings.vrm.scale-and-position.x')"
:format-value="val => val.toFixed(4)"
>
<template #label>
<div flex items-center>
<div>
{{ t('settings.vrm.scale-and-position.x') }}
</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.x = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange
v-model="modelOffset.y"
as="div"
:min="-modelSize.y - 10"
:max="modelSize.y + 10"
:step="modelSize.y / 100"
:label="t('settings.vrm.scale-and-position.y')"
:format-value="val => val.toFixed(4)"
>
<template #label>
<div flex items-center>
<div>{{ t('settings.vrm.scale-and-position.y') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.y = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange
v-model="modelOffset.z"
as="div"
:min="-modelSize.z - 10"
:max="modelSize.z + 10"
:step="modelSize.z / 100"
:label="t('settings.vrm.scale-and-position.z')"
:format-value="val => val.toFixed(4)"
>
<template #label>
<div flex items-center>
<div>{{ t('settings.vrm.scale-and-position.z') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.z = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
</Section>
<Section
:title="t('settings.vrm.change-model.title')"
icon="i-solar:magic-stick-3-bold-duotone"
@@ -99,96 +216,4 @@ modelFileDialog.onChange((files) => {
{{ t('settings.vrm.theme-color-from-model.button-extract.title') }}
</Button>
</Section>
<Section
:title="t('settings.vrm.scale-and-position.title')"
icon="i-solar:scale-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<Callout :label="t('settings.vrm.scale-and-position.model-info-title')">
<div>
<div class="text-sm text-neutral-600 space-y-1">
<div class="flex justify-between">
<span>{{ t('settings.vrm.scale-and-position.model-info-x') }}</span>
<span>{{ modelSize.x.toFixed(4) }}</span>
</div>
<div class="flex justify-between">
<span>{{ t('settings.vrm.scale-and-position.model-info-y') }}</span>
<span>{{ modelSize.y.toFixed(4) }}</span>
</div>
<div class="flex justify-between">
<span>{{ t('settings.vrm.scale-and-position.model-info-z') }}</span>
<span>{{ modelSize.z.toFixed(4) }}</span>
</div>
</div>
</div>
</Callout>
<Callout
theme="lime"
label="Tips!"
>
<div class="text-sm text-neutral-600 space-y-1">
{{ t('settings.vrm.scale-and-position.tips') }}
</div>
</Callout>
<FieldRange
:model-value="Number(modelOffset.x.toFixed(4))"
as="div"
:min="-modelSize.x"
:max="modelSize.x"
:step="modelSize.x / 100"
:label="t('settings.vrm.scale-and-position.x')"
@update:model-value="val => modelOffset.x = val"
>
<template #label>
<div flex items-center>
<div>
{{ t('settings.vrm.scale-and-position.x') }}
</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.x = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange
:model-value="Number(modelOffset.y.toFixed(4))"
as="div"
:min="-modelSize.y"
:max="modelSize.y"
:step="modelSize.y / 100"
:label="t('settings.vrm.scale-and-position.y')"
@update:model-value="val => modelOffset.y = val"
>
<template #label>
<div flex items-center>
<div>{{ t('settings.vrm.scale-and-position.y') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.y = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange
:model-value="Number(modelOffset.z.toFixed(4))"
as="div"
:min="-modelSize.z"
:max="modelSize.z"
:step="modelSize.z / 100"
:label="t('settings.vrm.scale-and-position.z')"
@update:model-value="val => modelOffset.z = val"
>
<template #label>
<div flex items-center>
<div>{{ t('settings.vrm.scale-and-position.z') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.z = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
</Section>
</template>
@@ -1,53 +0,0 @@
<script setup lang="ts">
import { OrbitControls } from '@tresjs/cientos'
import { TresCanvas } from '@tresjs/core'
import { useElementBounding } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useVRM } from '../../../../stores'
import { VRMModel } from '../../../Scenes'
const emit = defineEmits<{
(e: 'loadModelProgress', value: number): void
(e: 'error', value: unknown): void
}>()
const VRMContainerRef = ref<HTMLDivElement>()
const { width, height } = useElementBounding(VRMContainerRef)
const { selectedModel } = storeToRefs(useVRM())
const cameraPositionX = ref(0.17)
const cameraPositionY = ref(0.5)
const cameraPositionZ = ref(-1)
const modelRef = ref<{
setExpression: (expression: string) => void
}>()
defineExpose({
setExpression: (expression: string) => {
modelRef.value?.setExpression(expression)
},
})
</script>
<template>
<div ref="VRMContainerRef" w="100%" h="100%">
<TresCanvas :alpha="true" :antialias="true" :width="width" :height="height">
<OrbitControls />
<TresAxesHelper :size="1" />
<TresPerspectiveCamera :position="[cameraPositionX, cameraPositionY, cameraPositionZ]" />
<TresDirectionalLight :color="0xFFFFFF" :intensity="1.2" :position="[1, 1, 1]" />
<TresAmbientLight :color="0xFFFFFF" :intensity="1.5" />
<VRMModel
ref="modelRef"
:key="selectedModel"
:model="selectedModel"
idle-animation="/assets/vrm/animations/idle_loop.vrma"
:paused="false"
@load-model-progress="(val) => emit('loadModelProgress', val)"
@error="(val) => emit('error', val)"
/>
</TresCanvas>
</div>
</template>
@@ -1,5 +1 @@
export { default as ModelSettings } from './index.vue'
export { default as ModelSettingsLive2D } from './Live2D.vue'
export { default as ModelSettingsLive2DScene } from './Live2DScene.vue'
export { default as ModelSettingsVRM } from './VRM.vue'
export { default as ModelSettingsVRMScene } from './VRMScene.vue'
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Live2DScene from '../../../Scenes/Live2D.vue'
import VRMScene from '../../../Scenes/VRM.vue'
import Live2D from './Live2D.vue'
import Live2DScene from './Live2DScene.vue'
import VRM from './VRM.vue'
import VRMScene from './VRMScene.vue'
import { useSettings } from '../../../../stores/settings'
@@ -12,6 +12,8 @@ const props = defineProps<{
palette: string[]
live2dSceneClass?: string | string[]
live2dSettingsClass?: string | string[]
vrmSceneClass?: string | string[]
vrmSettingsClass?: string | string[]
}>()
defineEmits<{
@@ -24,13 +26,15 @@ const { stageView } = storeToRefs(useSettings())
<template>
<!-- Live2D component for 2D stage view -->
<template v-if="stageView === '2d'">
<Live2DScene
<div
:class="[
...(props.live2dSceneClass
? (typeof props.live2dSceneClass === 'string' ? [props.live2dSceneClass] : props.live2dSceneClass)
: []),
]"
/>
>
<Live2DScene />
</div>
<div
flex="~ col gap-2" :class="[
...(props.live2dSettingsClass
@@ -43,9 +47,26 @@ const { stageView } = storeToRefs(useSettings())
</template>
<!-- VRM component for 3D stage view -->
<template v-if="stageView === '3d'">
<VRMScene />
<div flex="~ col gap-2">
<VRM :palette="palette" @extract-colors-from-model="$emit('extractColorsFromModel')" @switch-to-live-2-d="stageView = '2d'" />
<div
:class="[
...(props.vrmSceneClass
? (typeof props.vrmSceneClass === 'string' ? [props.vrmSceneClass] : props.vrmSceneClass)
: []),
]"
>
<VRMScene />
</div>
<div h-full w-full p-2>
<div
flex="~ col gap-2"
:class="[
...(props.vrmSettingsClass
? (typeof props.vrmSettingsClass === 'string' ? [props.vrmSettingsClass] : props.vrmSettingsClass)
: []),
]"
>
<VRM :palette="palette" @extract-colors-from-model="$emit('extractColorsFromModel')" @switch-to-live-2-d="stageView = '2d'" />
</div>
</div>
</template>
</template>
@@ -1,32 +1,23 @@
<script setup lang="ts">
import { TransitionVertical } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Screen from '../Misc/Screen.vue'
import Live2DCanvas from './Live2D/Canvas.vue'
import Live2DModel from './Live2D/Model.vue'
import { useLive2d } from '../../stores'
import '../../utils/live2d-zip-loader'
withDefaults(defineProps<{
paused: boolean
paused?: boolean
mouthOpenSize?: number
focusAt: { x: number, y: number }
focusAt?: { x: number, y: number }
xOffset?: number | string
yOffset?: number | string
scale?: number
}>(), {
paused: false,
focusAt: () => ({ x: 0, y: 0 }),
mouthOpenSize: 0,
scale: 1,
})
const { t } = useI18n()
const show = ref(false)
const { currentMotion } = storeToRefs(useLive2d())
</script>
<template>
@@ -50,80 +41,5 @@ const { currentMotion } = storeToRefs(useLive2d())
:scale="scale"
/>
</Live2DCanvas>
<div absolute bottom="3" right="3">
<div flex="~ row" cursor-pointer>
<label
:class="[show ? 'bg-neutral-300 dark:bg-neutral-200' : 'bg-neutral-100 dark:bg-neutral-700']"
transition="all ease-in-out duration-500"
text="lg neutral-500 dark:neutral-400"
m-1 h-fit w-fit cursor-pointer appearance-none gap-1 rounded-lg rounded-md border-none p-2 outline-none
>
<input
v-model="show"
:checked="show"
:aria-checked="show"
name="showLive2DViewerInspector"
type="checkbox"
appearance-none outline-none hidden
>
<div select-none>
<div i-solar:bug-bold-duotone />
</div>
</label>
</div>
<TransitionVertical>
<div v-if="show" min-w="50vw" z="<md:20" class="bottom-11 right-0" absolute m-1 w-full>
<div bg="neutral-200/20 dark:black/20" rounded-lg p-2 backdrop-blur-sm>
<div font-mono>
<span>{{ t('stage.viewers.debug-menu.emotions') }}</span>
</div>
<div flex="~ row" flex-wrap gap-2>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="currentMotion = { group: 'Surprise', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.surprised') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="currentMotion = { group: 'Sad', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.sad') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="currentMotion = { group: 'Angry', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.angry') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="currentMotion = { group: 'Happy', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.happy') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="currentMotion = { group: 'Awkward', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.awkward') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="currentMotion = { group: 'Question', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.question') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="currentMotion = { group: 'Think', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.think') }}
</button>
</div>
</div>
</div>
</TransitionVertical>
</div>
</Screen>
</template>
@@ -41,7 +41,7 @@ const db = ref<DuckDBWasmDrizzleDatabase>()
const vrmViewerRef = ref<{ setExpression: (expression: string) => void }>()
const { stageView } = storeToRefs(useSettings())
const { stageView, stageViewControlsEnabled } = storeToRefs(useSettings())
const { mouthOpenSize } = storeToRefs(useSpeakingStore())
const { audioContext, calculateVolume } = useAudioContext()
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd, onAssistantResponseEnd } = useChatStore()
@@ -245,6 +245,7 @@ onMounted(async () => {
idle-animation="/assets/vrm/animations/idle_loop.vrma"
min-w="50% <lg:full" min-h="100 sm:100" h-full w-full flex-1
:paused="paused"
:show-axes="stageViewControlsEnabled"
@error="console.error"
/>
</div>
+17 -163
View File
@@ -1,44 +1,35 @@
<script setup lang="ts">
import { TransitionVertical } from '@proj-airi/ui'
import { OrbitControls } from '@tresjs/cientos'
import { TresCanvas } from '@tresjs/core'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DataGuiRange from '../DataGui/Range.vue'
import Collapsable from '../Misc/Collapsable.vue'
import Screen from '../Misc/Screen.vue'
import VRMModel from './VRM/Model.vue'
import { useVRM } from '../../stores'
import { Screen } from '../Misc'
import { VRMModel } from '../Scenes'
const props = defineProps<{
paused: boolean
}>()
const props = withDefaults(defineProps<{
paused?: boolean
showAxes?: boolean
}>(), {
paused: false,
showAxes: false,
})
const emit = defineEmits<{
(e: 'loadModelProgress', value: number): void
(e: 'error', value: unknown): void
}>()
const { selectedModel, scale } = storeToRefs(useVRM())
const show = ref(false)
const { selectedModel } = storeToRefs(useVRM())
const cameraPositionX = ref(-0.17)
const cameraPositionY = ref(0)
const cameraPositionX = ref(0.17)
const cameraPositionY = ref(0.5)
const cameraPositionZ = ref(-1)
const vrmModelPositionX = ref(-0.18)
const vrmModelPositionY = ref(-1.32)
const vrmModelPositionZ = ref(-0.24)
const modelRef = ref<{
setExpression: (expression: string) => void
}>()
const { t } = useI18n()
defineExpose({
setExpression: (expression: string) => {
modelRef.value?.setExpression(expression)
@@ -47,14 +38,16 @@ defineExpose({
</script>
<template>
<Screen v-slot="{ height, width }" relative>
<Screen v-slot="{ width, height }" relative>
<TresCanvas :alpha="true" :antialias="true" :width="width" :height="height">
<OrbitControls />
<TresPerspectiveCamera :position="[cameraPositionX, cameraPositionY, cameraPositionZ]" />
<OrbitControls :enable-zoom="false" />
<TresAxesHelper v-if="props.showAxes" :size="1" />
<TresPerspectiveCamera :position="[cameraPositionX, cameraPositionY, cameraPositionZ]" :zoom="scale" />
<TresDirectionalLight :color="0xFFFFFF" :intensity="1.2" :position="[1, 1, 1]" />
<TresAmbientLight :color="0xFFFFFF" :intensity="1.5" />
<VRMModel
ref="modelRef"
:key="selectedModel"
:model="selectedModel"
idle-animation="/assets/vrm/animations/idle_loop.vrma"
:paused="props.paused"
@@ -62,144 +55,5 @@ defineExpose({
@error="(val) => emit('error', val)"
/>
</TresCanvas>
<div absolute bottom="3" right="3">
<div flex="~ row" cursor-pointer>
<label
:class="[show ? 'bg-neutral-300 dark:bg-neutral-200' : 'bg-neutral-100 dark:bg-neutral-700']"
transition="all ease-in-out duration-500"
text="lg neutral-500 dark:neutral-400"
m-1 h-fit w-fit cursor-pointer appearance-none gap-1 rounded-lg rounded-md border-none p-2 outline-none
>
<input
v-model="show"
:checked="show"
:aria-checked="show"
name="showLive2DViewerInspector"
type="checkbox"
appearance-none outline-none hidden
>
<div select-none>
<div i-solar:bug-bold-duotone />
</div>
</label>
</div>
<TransitionVertical>
<div v-if="show" min-w="50vw" z="<md:20" class="bottom-11 right-0" absolute m-1 w-full>
<div bg="neutral-200/20 dark:black/20" flex="~ col" gap-2 rounded-lg p-2 backdrop-blur-sm>
<div font-mono>
<span>{{ t('stage.viewers.debug-menu.vrm.model.title') }}</span>
</div>
<Collapsable h-fit w-full flex="~ col" border="~ gray/25 rounded-lg" divide="y dashed gray/25" of-clip shadow-sm>
<template #label>
<span font-mono>{{ t('stage.viewers.debug-menu.vrm.camera') }}</span>
</template>
<div grid="~ cols-[20px_1fr_60px]" w-full gap-1 p-2 text-sm font-mono>
<div text="neutral-400 dark:neutral-500">
<span>X</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="cameraPositionX" :min="-10" :max="10" :step="0.01" />
</label>
<div text-right>
<span>{{ cameraPositionX }}</span>
</div>
<div text="neutral-400 dark:neutral-500">
<span>Y</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="cameraPositionY" :min="-10" :max="10" :step="0.01" />
</label>
<div text-right>
<span>{{ cameraPositionY }}</span>
</div>
<div text="neutral-400 dark:neutral-500">
<span>Z</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="cameraPositionZ" :min="-10" :max="10" :step="0.01" />
</label>
<div text-right>
<span>{{ cameraPositionZ }}</span>
</div>
</div>
</Collapsable>
<Collapsable h-fit w-full flex="~ col" border="~ gray/25 rounded-lg" divide="y dashed gray/25" of-clip shadow-sm>
<template #label>
<span font-mono>{{ t('stage.viewers.debug-menu.vrm.model.title') }}</span>
</template>
<div grid="~ cols-[20px_1fr_60px]" w-full gap-1 p-2 text-sm font-mono>
<div text="neutral-400 dark:neutral-500">
<span>X</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="vrmModelPositionX" :min="-10" :max="10" :step="0.01" />
</label>
<div text-right>
<span>{{ vrmModelPositionX }}</span>
</div>
<div text="neutral-400 dark:neutral-500">
<span>Y</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="vrmModelPositionY" :min="-10" :max="10" :step="0.01" />
</label>
<div text-right>
<span>{{ vrmModelPositionY }}</span>
</div>
<div text="neutral-400 dark:neutral-500">
<span>Z</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="vrmModelPositionZ" :min="-10" :max="10" :step="0.01" />
</label>
<div text-right>
<span>{{ vrmModelPositionZ }}</span>
</div>
</div>
</Collapsable>
<div font-mono>
<span>{{ t('stage.viewers.debug-menu.emotions') }}</span>
</div>
<div flex="~ row" w-full flex-wrap gap-2>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="modelRef?.setExpression('neutral')"
>
{{ t('stage.viewers.debug-menu.emotions-btn.neutral') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="modelRef?.setExpression('surprised')"
>
{{ t('stage.viewers.debug-menu.emotions-btn.surprised') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="modelRef?.setExpression('sad')"
>
{{ t('stage.viewers.debug-menu.emotions-btn.sad') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="modelRef?.setExpression('angry')"
>
{{ t('stage.viewers.debug-menu.emotions-btn.angry') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="modelRef?.setExpression('happy')"
>
{{ t('stage.viewers.debug-menu.emotions-btn.happy') }}
</button>
</div>
</div>
</div>
</TransitionVertical>
</div>
</Screen>
</template>
@@ -40,15 +40,16 @@ const {
modelOffset,
modelOrigin,
modelSize,
modelPosition,
position,
scale,
} = storeToRefs(vrmStore)
watch(modelOffset, () => {
if (vrm.value) {
vrm.value.scene.position.set(
modelPosition.value.x,
modelPosition.value.y,
modelPosition.value.z,
position.value.x,
position.value.y,
position.value.z,
)
}
}, { deep: true })
@@ -69,9 +70,10 @@ onMounted(async () => {
console.warn('No VRM model loaded')
return
}
const { _vrm, modelCenter: vrmModelCenter, modelSize: vrmModelSize } = _vrmInfo
// Set initial postions for model
// Set initial positions for model
modelOrigin.value = {
x: vrmModelCenter.x,
y: vrmModelCenter.y,
@@ -83,7 +85,7 @@ onMounted(async () => {
z: vrmModelSize.z,
}
// Set initial positons for animation
// Set initial positions for animation
function removeRootPositionTrack(clip: AnimationClip) {
clip.tracks = clip.tracks.filter((track) => {
return !track.name.endsWith('.position')
@@ -96,6 +98,7 @@ onMounted(async () => {
console.warn('No VRM animation loaded')
return
}
removeRootPositionTrack(clip)
// play animation
@@ -105,6 +108,7 @@ onMounted(async () => {
vrmEmote.value = useVRMEmote(_vrm)
vrm.value = _vrm
vrm.value.scene.scale.set(scale.value, scale.value, scale.value)
disposeBeforeRenderLoop = onBeforeRender(({ delta }) => {
vrmAnimationMixer.value?.update(delta)
@@ -138,6 +142,12 @@ const { pause, resume } = useLoop()
watch(() => props.paused, (value) => {
value ? pause() : resume()
})
watch(scale, () => {
if (vrm.value) {
vrm.value.scene.scale.set(scale.value, scale.value, scale.value)
}
}, { immediate: true })
</script>
<template>
+2
View File
@@ -13,6 +13,7 @@ export const useSettings = defineStore('settings', () => {
const language = useLocalStorage('settings/language', 'en')
const stageView = useLocalStorage('settings/stage/view/model-renderer', '2d')
const stageViewControlsEnabled = ref(false)
const isAudioInputOn = useLocalStorage('settings/audio/input', 'false')
const selectedAudioDeviceId = computed(() => selectedAudioDevice.value?.deviceId)
@@ -77,6 +78,7 @@ export const useSettings = defineStore('settings', () => {
usePageSpecificTransitions,
language,
stageView,
stageViewControlsEnabled,
themeColorsHue,
themeColorsHueDynamic,
isAudioInputOn,
+9 -5
View File
@@ -7,18 +7,21 @@ export const useVRM = defineStore('vrm', () => {
const modelUrl = ref<string>('/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm')
const loadSource = ref<'file' | 'url'>('url')
const loadingModel = ref(false)
const scale = useLocalStorage('settings/live2d/cameraScale', 1)
const modelSize = useLocalStorage('settings/vrm/modelSize', { x: 0, y: 0, z: 0 })
const modelOrigin = useLocalStorage('settings/vrm/modelOrigin', { x: 0, y: 0, z: 0 })
const modelOffset = useLocalStorage('settings/vrm/modelOffset', { x: 0, y: 0, z: 0 })
const modelPosition = computed(() => ({
const position = computed(() => ({
x: modelOrigin.value.x + modelOffset.value.x,
y: modelOrigin.value.y + modelOffset.value.y,
z: modelOrigin.value.z + modelOffset.value.z,
}))
const positionInPercentageString = computed(() => ({
x: `${modelPosition.value.x}%`,
y: `${modelPosition.value.y}%`,
z: `${modelPosition.value.z}%`,
x: `${position.value.x}%`,
y: `${position.value.y}%`,
z: `${position.value.z}%`,
}))
const modelObjectUrl = ref<string>()
@@ -52,9 +55,10 @@ export const useVRM = defineStore('vrm', () => {
loadSource,
loadingModel,
modelSize,
scale,
modelOrigin,
modelOffset,
modelPosition,
position,
positionInPercentageString,
selectedModel, // Expose the new computed property
}
@@ -153,6 +153,7 @@ https://toughengineer.github.io/demo/slider-styler*/
border: var(--track-border);
border-radius: var(--track-border-radius);
background: var(--track-background);
backdrop-filter: blur(8px);
box-shadow: var(--track-box-shadow);
position: relative;
cursor: col-resize;
@@ -225,6 +226,7 @@ https://toughengineer.github.io/demo/slider-styler*/
border: var(--track-border);
border-radius: var(--track-border-radius);
background: var(--track-background);
backdrop-filter: blur(8px);
box-shadow: var(--track-box-shadow);
cursor: col-resize;
/* Trim left and right paddings of track */
@@ -307,6 +309,7 @@ https://toughengineer.github.io/demo/slider-styler*/
height: var(--track-height);
border-radius: var(--track-border-radius);
background: var(--track-background);
backdrop-filter: blur(8px);
border: var(--track-border);
box-shadow: var(--track-box-shadow);
box-sizing: border-box;
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
min?: number
max?: number
step?: number
disabled?: boolean
}>(), {
min: 0,
max: 100,
step: 1,
disabled: false,
})
const modelValue = defineModel<number>({ required: true })
const scaledMin = computed(() => props.min * 10000)
const scaledMax = computed(() => props.max * 10000)
const scaledStep = computed(() => props.step * 10000)
const sliderRef = ref<HTMLInputElement>()
const sliderValue = computed({
get: () => modelValue.value * 10000,
set: (value: number) => {
modelValue.value = value / 10000
updateTrackColor()
},
})
onMounted(() => updateTrackColor())
watch(sliderValue, () => updateTrackColor(), { immediate: true })
watch([scaledMin, scaledMax, scaledStep], () => updateTrackColor(), { immediate: true })
function updateTrackColor() {
if (!sliderRef.value) {
return
}
sliderRef.value.style.setProperty('--value', sliderValue.value.toString())
sliderRef.value.style.setProperty('--min', !sliderRef.value.min ? props.min.toString() : sliderRef.value.min)
sliderRef.value.style.setProperty('--max', !sliderRef.value.max ? props.max.toString() : sliderRef.value.max)
}
function handleInput(e: Event) {
const target = e.target as HTMLInputElement
target.style.setProperty('--value', target.value)
}
</script>
<template>
<input
ref="sliderRef"
v-model.number="sliderValue"
type="range"
:min="scaledMin"
:max="scaledMax"
:step="scaledStep"
class="slider-progress form_input-round-range"
@input="handleInput"
>
</template>
<style scoped>
/*generated with Input range slider CSS style generator (version 20211225)
https://toughengineer.github.io/demo/slider-styler*/
.form_input-round-range {
--height: 2em;
min-height: var(--height);
appearance: none;
background: transparent;
border-radius: 4px;
transition: background-color 0.2s ease;
--thumb-width: var(--height);
--thumb-height: 0px;
--thumb-box-shadow: none;
--thumb-border: none;
--thumb-border-radius: 0px;
--thumb-background: rgb(255, 255, 255);
--track-height: calc(var(--height) - var(--track-value-padding) * 2);
--track-box-shadow: 0 0 12px -2px rgb(0 0 0 / 22%);
--track-border: none;
--track-border-radius: 10px;
--track-background: rgba(0, 0, 0, 0.4);
--track-value-background: rgb(255, 255, 255);
--track-value-padding: 0px;
}
[data-direction="vertical"].form_input-round-range {
transform: rotate(180deg);
}
.dark .form_input-round-range {
--thumb-background: rgb(238, 238, 238);
--track-border: none;
--track-background: rgba(99, 99, 99, 0.7);
--track-box-shadow: 0 0 12px -2px rgb(0 0 0 / 22%);
--track-value-background: rgb(238, 238, 238);
}
/*progress support*/
.form_input-round-range.slider-progress {
--range: calc(var(--max) - var(--min));
--ratio: calc((var(--value) - var(--min)) / var(--range));
--sx: calc(0.5 * 0em + var(--ratio) * (100% - 0em));
}
.form_input-round-range:focus {
outline: none;
}
/*webkit*/
.form_input-round-range::-webkit-slider-thumb {
appearance: none;
width: var(--thumb-width);
height: var(--thumb-height);
border-radius: var(--thumb-border-radius);
background: var(--thumb-background);
border: var(--thumb-border);
box-shadow: var(--thumb-box-shadow);
margin-top: 0px;
margin-left: calc(0 - var(--track-value-padding));
cursor: col-resize;
transition:
background 0.2s ease-in-out,
box-shadow 0.2s ease-in-out,
border-color 0.2s ease-in-out,
transform 0.2s cubic-bezier(0.165, 0.84, 0.44, 1),
width 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
}
[data-direction="vertical"]::-webkit-slider-thumb {
cursor: ns-resize;
}
.form_input-round-range::-webkit-slider-runnable-track {
height: var(--track-height);
border: var(--track-border);
border-radius: var(--track-border-radius);
background: var(--track-background);
backdrop-filter: blur(4px);
box-shadow: var(--track-box-shadow);
position: relative;
cursor: col-resize;
overflow: hidden;
transition:
box-shadow 0.2s ease-in-out,
border-color 0.2s ease-in-out;
}
[data-direction="vertical"]::-webkit-slider-runnable-track {
cursor: ns-resize;
}
.form_input-round-range.slider-progress::-webkit-slider-runnable-track {
background:
linear-gradient(var(--track-value-background), var(--track-value-background)) 0 / var(--sx) 100% no-repeat,
var(--track-background);
}
[data-direction="vertical"].form_input-round-range.slider-progress::-webkit-slider-runnable-track {
background: linear-gradient(var(--track-value-background) var(--sx), var(--track-background) var(--sx)) no-repeat;
}
/*mozilla*/
.form_input-round-range::-moz-range-thumb {
width: var(--thumb-width);
height: var(--thumb-height);
border-radius: var(--thumb-border-radius);
background: var(--thumb-background);
border: none;
box-shadow: var(--thumb-box-shadow);
cursor: col-resize;
margin-left: calc(0 - var(--track-value-padding));
transition:
background 0.2s ease-in-out,
box-shadow 0.2s ease-in-out,
border-color 0.2s ease-in-out,
transform 0.2s cubic-bezier(0.165, 0.84, 0.44, 1),
width 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
}
[data-direction="vertical"]::-moz-range-thumb {
cursor: ns-resize;
}
.form_input-round-range::-moz-range-track {
height: var(--track-height);
border: var(--track-border);
border-radius: var(--track-border-radius);
background: var(--track-background);
backdrop-filter: blur(4px);
box-shadow: var(--track-box-shadow);
cursor: col-resize;
overflow: hidden;
/* Trim left and right paddings of track */
width: calc(100% - var(--track-value-padding) * 2);
}
[data-direction="vertical"]::-moz-range-track {
cursor: ns-resize;
}
.form_input-round-range.slider-progress::-moz-range-track {
background:
linear-gradient(var(--track-value-background), var(--track-value-background)) 0 / var(--sx) 100% no-repeat,
var(--track-background);
}
[data-direction="vertical"].form_input-round-range.slider-progress::-moz-range-track {
background: linear-gradient(var(--track-value-background) var(--sx), var(--track-background) var(--sx)) no-repeat;
}
/*ms*/
.form_input-round-range::-ms-fill-upper {
background: transparent;
border-color: transparent;
}
.form_input-round-range::-ms-fill-lower {
background: transparent;
border-color: transparent;
}
.form_input-round-range::-ms-thumb {
width: var(--thumb-width);
height: var(--thumb-height);
border-radius: var(--thumb-border-radius);
background: var(--thumb-background);
border: var(--thumb-border);
box-shadow: var(--thumb-box-shadow);
box-sizing: border-box;
cursor: col-resize;
transition:
background 0.2s ease-in-out,
box-shadow 0.2s ease-in-out,
border-color 0.2s ease-in-out,
transform 0.2s cubic-bezier(0.165, 0.84, 0.44, 1),
width 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
}
[data-direction="vertical"]::-ms-thumb {
cursor: ns-resize;
}
.form_input-round-range::-ms-track {
height: var(--track-height);
border-radius: var(--track-border-radius);
background: var(--track-background);
backdrop-filter: blur(4px);
border: var(--track-border);
box-shadow: var(--track-box-shadow);
box-sizing: border-box;
cursor: col-resize;
overflow: hidden;
}
[data-direction="vertical"]::-ms-track {
cursor: ns-resize;
}
.form_input-round-range.slider-progress::-ms-fill-lower {
height: var(--track-height);
border-radius: var(--track-border-radius) 0 0 var(--track-border-radius);
margin: 0;
background: var(--track-value-background);
border: none;
border-right-width: 0;
}
</style>
@@ -1,2 +1,3 @@
export { default as ColorHueRange } from './ColorHueRange.vue'
export { default as Range } from './Range.vue'
export { default as RoundRange } from './RoundRange.vue'