feat(stage-*): new Model Selector to select models for Live2D, VRM, and future MMD
Close #426 Close #414 Close #375
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { AiriTamagotchiEvents } from './composables/tauri'
|
||||
|
||||
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
|
||||
import { useMcpStore } from '@proj-airi/stage-ui/stores/mcp'
|
||||
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
|
||||
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
|
||||
@@ -16,22 +17,27 @@ import { useTauriEvent } from './composables/tauri'
|
||||
import { useWindowMode } from './stores/window-controls'
|
||||
|
||||
useWindowMode()
|
||||
const { language, themeColorsHue, themeColorsHueDynamic, allowVisibleOnAllWorkspaces } = storeToRefs(useSettings())
|
||||
const i18n = useI18n()
|
||||
const mcpStore = useMcpStore()
|
||||
const displayModelsStore = useDisplayModelsStore()
|
||||
const settingsStore = useSettings()
|
||||
const { language, themeColorsHue, themeColorsHueDynamic, allowVisibleOnAllWorkspaces } = storeToRefs(settingsStore)
|
||||
const onboardingStore = useOnboardingStore()
|
||||
const { shouldShowSetup } = storeToRefs(onboardingStore)
|
||||
const { platform } = useAppRuntime()
|
||||
|
||||
const mcpStore = useMcpStore()
|
||||
const { listen } = useTauriEvent<AiriTamagotchiEvents>()
|
||||
const { platform } = useAppRuntime()
|
||||
|
||||
watch(language, () => {
|
||||
i18n.locale.value = language.value
|
||||
})
|
||||
|
||||
// FIXME: store settings to file
|
||||
onMounted(() => {
|
||||
// Initialize first-time setup check
|
||||
onMounted(async () => {
|
||||
onboardingStore.initializeSetupCheck()
|
||||
|
||||
await displayModelsStore.loadDisplayModelsFromIndexedDB()
|
||||
await settingsStore.initializeStageModel()
|
||||
})
|
||||
|
||||
watch(themeColorsHue, () => {
|
||||
|
||||
@@ -45,10 +45,10 @@ const {
|
||||
<template>
|
||||
<div flex class="relative h-full flex-col-reverse md:flex-row">
|
||||
<ModelSettings
|
||||
settings-class="w-100% md:w-40% lg:w-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
|
||||
|
||||
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-40% xl:w-25% 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-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
|
||||
:palette="palette" @extract-colors-from-model="extractColorsFromModel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { OnboardingDialog, ToasterRoot } from '@proj-airi/stage-ui/components'
|
||||
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
|
||||
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
|
||||
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { StageTransitionGroup } from '@proj-airi/ui-transitions'
|
||||
@@ -16,7 +17,9 @@ import 'vue-sonner/style.css'
|
||||
|
||||
usePWAStore()
|
||||
const i18n = useI18n()
|
||||
const settings = storeToRefs(useSettings())
|
||||
const displayModelsStore = useDisplayModelsStore()
|
||||
const settingsStore = useSettings()
|
||||
const settings = storeToRefs(settingsStore)
|
||||
const onboardingStore = useOnboardingStore()
|
||||
const { shouldShowSetup } = storeToRefs(onboardingStore)
|
||||
const isDark = useDark()
|
||||
@@ -56,8 +59,11 @@ watch(settings.themeColorsHueDynamic, () => {
|
||||
}, { immediate: true })
|
||||
|
||||
// Initialize first-time setup check when app mounts
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
onboardingStore.initializeSetupCheck()
|
||||
|
||||
await displayModelsStore.loadDisplayModelsFromIndexedDB()
|
||||
await settingsStore.initializeStageModel()
|
||||
})
|
||||
|
||||
// Handle first-time setup events
|
||||
|
||||
@@ -45,10 +45,9 @@ const {
|
||||
<template>
|
||||
<div flex class="relative h-full flex-col-reverse md:flex-row">
|
||||
<ModelSettings
|
||||
settings-class="w-100% md:w-40% lg:w-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
|
||||
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-40% xl:w-25% 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-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
|
||||
:palette="palette" @extract-colors-from-model="extractColorsFromModel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -345,14 +345,14 @@ watch([hue, saturation, value, alphaValue], () => {
|
||||
<template>
|
||||
<PopoverRoot>
|
||||
<PopoverTrigger class="grid grid-col-span-3 grid-cols-3 h-fit items-center">
|
||||
<div :style="{ backgroundColor: modelValue }" grid-col-span-1 min-h-5 rounded-md />
|
||||
<div grid-col-span-2 font-mono text="[10px]">
|
||||
<div :style="{ backgroundColor: modelValue }" grid-col-span-2 min-h-5 rounded-md />
|
||||
<div grid-col-span-1 font-mono text="[10px] right">
|
||||
{{ modelValue }}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent align="start">
|
||||
<div class="mt-2 space-y-2" bg="white/90 dark:neutral-900/90" rounded-xl p-1>
|
||||
<PopoverContent align="start" class="relative z-20">
|
||||
<div class="z-20 mt-2 space-y-2" bg="white/90 dark:neutral-900/90" rounded-xl p-1>
|
||||
<!-- Color Map -->
|
||||
<div class="relative h-48 w-full select-none overflow-hidden rounded-lg">
|
||||
<div
|
||||
@@ -567,6 +567,12 @@ watch([hue, saturation, value, alphaValue], () => {
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
data-[data-reka-popper-content-wrapper=""] {
|
||||
z-index: 20;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.hue-slider,
|
||||
.alpha-slider {
|
||||
|
||||
@@ -14,42 +14,42 @@ const themeClasses: Record<ThemeVariant, {
|
||||
}> = {
|
||||
primary: {
|
||||
container: [
|
||||
'text-neutral-700/80 dark:text-neutral-300/80',
|
||||
'bg-primary-50/60 dark:bg-primary-900/25 backdrop-blur-md',
|
||||
`before:bg-primary-200 before:content-[''] before:dark:bg-primary-900`,
|
||||
'text-neutral-900/80 dark:text-neutral-100/80',
|
||||
'bg-primary-50/80 dark:bg-primary-900/50 backdrop-blur-md',
|
||||
`before:bg-primary-500/30 before:content-[''] before:dark:bg-primary-200/20`,
|
||||
],
|
||||
label: [
|
||||
'text-primary-500 dark:text-primary-400 font-semibold',
|
||||
'text-primary-500 dark:text-primary-200 font-semibold',
|
||||
],
|
||||
},
|
||||
lime: {
|
||||
container: [
|
||||
'text-neutral-700/80 dark:text-neutral-300/80',
|
||||
'bg-lime-50/60 dark:bg-lime-900/25 backdrop-blur-md',
|
||||
`before:bg-lime-200 before:content-[''] before:dark:bg-lime-900`,
|
||||
'text-neutral-900/80 dark:text-neutral-100/80',
|
||||
'bg-lime-50/80 dark:bg-lime-900/50 backdrop-blur-md',
|
||||
`before:bg-lime-500/30 before:content-[''] before:dark:bg-lime-200/20`,
|
||||
],
|
||||
label: [
|
||||
'text-lime-500 dark:text-lime-400 font-semibold',
|
||||
'text-lime-500 dark:text-lime-200 font-semibold',
|
||||
],
|
||||
},
|
||||
violet: {
|
||||
container: [
|
||||
'text-neutral-700/80 dark:text-neutral-300/80',
|
||||
'bg-violet-50/60 dark:bg-violet-900/25 backdrop-blur-md',
|
||||
`before:bg-violet-200 before:content-[''] before:dark:bg-violet-900`,
|
||||
'text-neutral-900/80 dark:text-neutral-100/80',
|
||||
'bg-violet-50/80 dark:bg-violet-900/50 backdrop-blur-md',
|
||||
`before:bg-violet-500/30 before:content-[''] before:dark:bg-violet-200/20`,
|
||||
],
|
||||
label: [
|
||||
'text-violet-500 dark:text-violet-400 font-semibold',
|
||||
'text-violet-500 dark:text-violet-200 font-semibold',
|
||||
],
|
||||
},
|
||||
orange: {
|
||||
container: [
|
||||
'text-neutral-700/80 dark:text-neutral-300/80',
|
||||
'bg-orange-50/70 dark:bg-orange-900/25 backdrop-blur-md',
|
||||
`before:bg-orange-200 before:content-[''] before:dark:bg-orange-900`,
|
||||
'text-neutral-900/80 dark:text-neutral-100/80',
|
||||
'bg-orange-100/60 dark:bg-orange-900/50 backdrop-blur-md',
|
||||
`before:bg-orange-500/30 before:content-[''] before:dark:bg-orange-200/20`,
|
||||
],
|
||||
label: [
|
||||
'text-orange-500 dark:text-orange-400 font-semibold',
|
||||
'text-orange-500 dark:text-orange-200 font-semibold',
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -59,7 +59,7 @@ const themeClasses: Record<ThemeVariant, {
|
||||
<div
|
||||
relative
|
||||
flex flex-col gap-1
|
||||
overflow-hidden rounded-lg
|
||||
rounded-lg
|
||||
py="2.5" pl="5" pr-3
|
||||
:class="[
|
||||
...themeClasses[props.theme || 'violet'].container,
|
||||
|
||||
@@ -28,8 +28,8 @@ const emit = defineEmits<{
|
||||
|
||||
const commonThemeClasses = {
|
||||
tabActive: [
|
||||
'bg-white shadow-sm font-bold text-blue-600 dark:text-blue-400 text-xs',
|
||||
'ring-2 ring-blue-500',
|
||||
'bg-white shadow-sm font-bold text-primary-600 dark:text-primary-400 text-xs',
|
||||
'ring-2 ring-primary-500',
|
||||
'dark:bg-neutral-700',
|
||||
'ring-1 ring-black/5 dark:ring-white/10',
|
||||
],
|
||||
@@ -117,6 +117,7 @@ function onClick(v: string, disabled?: boolean) {
|
||||
sizeCls,
|
||||
t.disabled ? 'opacity-40 cursor-not-allowed' : '',
|
||||
modelValue === t.value ? themeClasses[theme].tabActive : themeClasses[theme].tabIdle,
|
||||
'focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary-200 focus-visible:outline-offset-2',
|
||||
]"
|
||||
@click="onClick(t.value, t.disabled)"
|
||||
>
|
||||
@@ -140,7 +141,3 @@ function onClick(v: string, disabled?: boolean) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
button:focus-visible { outline: 2px solid rgb(147 197 253); outline-offset: 2px }
|
||||
</style>
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import type { DisplayModel } from '../../../../stores/display-models'
|
||||
|
||||
import { useMediaQuery, useResizeObserver, useScreenSafeArea } from '@vueuse/core'
|
||||
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger, VisuallyHidden } from 'reka-ui'
|
||||
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTrigger } from 'vaul-vue'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import ModelManager from './model-selector.vue'
|
||||
|
||||
const showDialog = defineModel('show', { type: Boolean, default: false, required: false })
|
||||
const selectedModel = defineModel<DisplayModel | undefined>({ type: Object, required: false })
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)')
|
||||
const screenSafeArea = useScreenSafeArea()
|
||||
|
||||
useResizeObserver(document.documentElement, () => screenSafeArea.update())
|
||||
onMounted(() => screenSafeArea.update())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-if="isDesktop" :open="showDialog" @update:open="value => showDialog = value">
|
||||
<DialogTrigger as-child>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
|
||||
<DialogContent class="fixed left-1/2 top-1/2 z-[9999] max-h-full max-w-5xl w-[92dvw] transform overflow-y-scroll rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>Models</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<ModelManager v-model="selectedModel" @close="showDialog = false" />
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
<DrawerRoot v-else :open="showDialog" should-scale-background @update:open="value => showDialog = value">
|
||||
<DrawerTrigger as-child>
|
||||
<slot />
|
||||
</DrawerTrigger>
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay class="fixed inset-0" />
|
||||
<DrawerContent class="fixed bottom-0 left-0 right-0 z-1000 mt-20 h-full max-h-[96%] flex flex-col rounded-t-2xl bg-neutral-50 px-4 pt-4 outline-none backdrop-blur-md dark:bg-neutral-900/95" :style="{ paddingBottom: `${Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 24)}px` }">
|
||||
<DrawerHandle />
|
||||
<ModelManager v-model="selectedModel" @close="showDialog = false" />
|
||||
</DrawerContent>
|
||||
</DrawerPortal>
|
||||
</DrawerRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import type { DisplayModel } from '../../../../stores/display-models'
|
||||
|
||||
import { BasicInputFile } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger, EditableArea, EditableEditTrigger, EditableInput, EditablePreview, EditableRoot, EditableSubmitTrigger } from 'reka-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import Button from '../../../Misc/Button.vue'
|
||||
|
||||
import { DisplayModelFormat, useDisplayModelsStore } from '../../../../stores/display-models'
|
||||
|
||||
const emits = defineEmits<{ (e: 'close', value: void): void }>()
|
||||
const selectedModel = defineModel<DisplayModel | undefined>({ type: Object, required: false })
|
||||
|
||||
const displayModelStore = useDisplayModelsStore()
|
||||
const { displayModelsFromIndexedDBLoading, displayModels } = storeToRefs(displayModelStore)
|
||||
|
||||
function handleRemoveModel(model: DisplayModel) {
|
||||
displayModelStore.removeDisplayModel(model.id)
|
||||
}
|
||||
|
||||
const highlightDisplayModelCard = ref<string | undefined>(selectedModel.value?.id)
|
||||
const live2dFiles = ref<File[]>([])
|
||||
const vrmFiles = ref<File[]>([])
|
||||
|
||||
function handleAddLive2DModel(file: File[]) {
|
||||
if (file.length === 0)
|
||||
return
|
||||
if (!file[0].name.endsWith('.zip'))
|
||||
return
|
||||
|
||||
displayModelStore.addDisplayModel(DisplayModelFormat.Live2dZip, file[0])
|
||||
}
|
||||
|
||||
function handlePick(m: DisplayModel) {
|
||||
selectedModel.value = m
|
||||
emits('close', undefined)
|
||||
}
|
||||
|
||||
function handleMobilePick() {
|
||||
selectedModel.value = displayModels.value.find(model => model.id === highlightDisplayModelCard.value)
|
||||
emits('close', undefined)
|
||||
}
|
||||
|
||||
watch(live2dFiles, (newFiles) => {
|
||||
handleAddLive2DModel(newFiles)
|
||||
}, { deep: true })
|
||||
|
||||
function handleAddVRMModel(file: File[]) {
|
||||
if (file.length === 0)
|
||||
return
|
||||
if (!file[0].name.endsWith('.vrm'))
|
||||
return
|
||||
|
||||
displayModelStore.addDisplayModel(DisplayModelFormat.VRM, file[0])
|
||||
}
|
||||
|
||||
const mapFormatRenderer: Record<DisplayModelFormat, string> = {
|
||||
[DisplayModelFormat.Live2dZip]: 'Live2D',
|
||||
[DisplayModelFormat.Live2dDirectory]: 'Live2D',
|
||||
[DisplayModelFormat.VRM]: 'VRM',
|
||||
[DisplayModelFormat.PMXDirectory]: 'MMD',
|
||||
[DisplayModelFormat.PMD]: 'MMD',
|
||||
}
|
||||
|
||||
watch(vrmFiles, (newFiles) => {
|
||||
handleAddVRMModel(newFiles)
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div pt="4 sm:0" gap="4 sm:6" h-full flex flex-col>
|
||||
<div flex items-center>
|
||||
<div w-full flex-1 text-xl>
|
||||
Model Selector
|
||||
</div>
|
||||
<div>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger
|
||||
bg="neutral-400/20 hover:neutral-400/45 active:neutral-400/60 dark:neutral-700/50 hover:dark:neutral-700/65 active:dark:neutral-700/90"
|
||||
flex items-center justify-center gap-1 rounded-lg px-2 py-1 backdrop-blur-sm
|
||||
transition="colors duration-200 ease-in-out"
|
||||
aria-label="Options for Display Models"
|
||||
>
|
||||
<div i-solar:add-circle-bold />
|
||||
<div>Add</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
class="will-change-[opacity,transform] z-10000 max-w-45 rounded-lg p-0.5 text-white shadow-md outline-none data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade dark:text-black"
|
||||
bg="neutral-700/50 dark:neutral-950/50"
|
||||
transition="colors duration-200 ease-in-out"
|
||||
backdrop-blur-sm
|
||||
align="end"
|
||||
side="bottom"
|
||||
:side-offset="8"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
class="data-[disabled]:text-mauve8 relative flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-base leading-none outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-primary-100/20 sm:text-sm data-[highlighted]:text-primary-200"
|
||||
transition="colors duration-200 ease-in-out"
|
||||
>
|
||||
<BasicInputFile v-model="live2dFiles" accept=".zip">
|
||||
Live2D
|
||||
</BasicInputFile>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
class="data-[disabled]:text-mauve8 relative flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-base leading-none outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-primary-100/20 sm:text-sm data-[highlighted]:text-primary-200"
|
||||
transition="colors duration-200 ease-in-out"
|
||||
>
|
||||
<BasicInputFile v-model="vrmFiles" accept=".vrm">
|
||||
VRM
|
||||
</BasicInputFile>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="displayModelsFromIndexedDBLoading">
|
||||
Loading display models...
|
||||
</div>
|
||||
<div class="flex-1 md:flex-none" h-full w-full>
|
||||
<div class="flex flex-1 flex-row gap-2 overflow-x-scroll md:grid lg:grid-cols-2 md:grid-cols-2 lg:max-h-80dvh md:overflow-y-scroll">
|
||||
<div
|
||||
v-for="(model) of displayModels"
|
||||
:key="model.id"
|
||||
v-auto-animate
|
||||
relative w-full flex="~ col md:row" gap-2
|
||||
@click="() => highlightDisplayModelCard = model.id"
|
||||
>
|
||||
<div absolute left-3 top-4 z-1>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger
|
||||
bg="neutral-900/20 hover:neutral-900/45 active:neutral-900/60 dark:neutral-950/50 hover:dark:neutral-900/65 active:dark:neutral-900/90"
|
||||
text="white"
|
||||
h-7 w-7 flex items-center justify-center rounded-lg backdrop-blur-sm
|
||||
transition="colors duration-200 ease-in-out"
|
||||
aria-label="Options for Display Models"
|
||||
>
|
||||
<div i-solar:menu-dots-bold />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
class="will-change-[opacity,transform] z-10000 max-w-45 rounded-lg p-0.5 text-white shadow-md outline-none data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade dark:text-black"
|
||||
bg="neutral-700/50 dark:neutral-950/50"
|
||||
transition="colors duration-200 ease-in-out"
|
||||
backdrop-blur-sm
|
||||
align="start"
|
||||
side="bottom"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
class="data-[disabled]:text-mauve8 relative flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-base leading-none outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-red-100/20 sm:text-sm data-[highlighted]:text-red-200"
|
||||
transition="colors duration-200 ease-in-out"
|
||||
>
|
||||
<button flex items-center gap-1 outline-none @click="handleRemoveModel(model)">
|
||||
<div i-solar:trash-bin-minimalistic-bold-duotone />
|
||||
<div>Remove</div>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
<div
|
||||
class="min-w-65 lg:min-h-60"
|
||||
aspect="12/16"
|
||||
px-1 py-2
|
||||
>
|
||||
<img v-if="model.previewImage" :src="model.previewImage" h-full w-full rounded-lg object-cover :class="[highlightDisplayModelCard && highlightDisplayModelCard === model.id ? 'ring-3 ring-primary-400' : 'ring-0 ring-transparent']" transition="all duration-200 ease-in-out">
|
||||
<div v-else bg="neutral-100 dark:neutral-900" relative h-full w-full flex flex-col items-center justify-center gap-2 rounded-lg :class="[highlightDisplayModelCard && highlightDisplayModelCard === model.id ? 'ring-4 ring-primary-400' : 'ring-0 ring-transparent']" transition="all duration-200 ease-in-out">
|
||||
<div i-solar:question-square-bold-duotone text-4xl opacity-75 />
|
||||
<div translate-y="100%" absolute top-0 flex flex-col translate-x--7 rotate-45 scale-250 gap-0 opacity-5>
|
||||
<div text="sm sm:sm" translate-x-7 translate-y--2 text-nowrap>
|
||||
unavailable Preview unavailable Preview
|
||||
</div>
|
||||
<div text="sm sm:sm" translate-x-0 translate-y--0 text-nowrap>
|
||||
Preview unavailable Preview unavailable
|
||||
</div>
|
||||
<div text="sm sm:sm" translate-x--7 translate-y-2 text-nowrap>
|
||||
unavailable Preview unavailable Preview
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div h-full w-full flex flex-col>
|
||||
<div w-full flex-1 p-2>
|
||||
<EditableRoot
|
||||
v-slot="{ isEditing }"
|
||||
:default-value="model.name"
|
||||
placeholder="Model Name..."
|
||||
class="flex gap-2"
|
||||
auto-resize
|
||||
>
|
||||
<EditableArea class="w-[calc(100%-8px-1rem)] dark:text-white">
|
||||
<EditablePreview class="line-clamp-1 w-[calc(100%-8px)] overflow-hidden text-ellipsis" />
|
||||
<EditableInput class="w-[calc(100%-8px)]! placeholder:text-neutral-700 dark:placeholder:text-neutral-600" />
|
||||
</EditableArea>
|
||||
<EditableEditTrigger v-if="!isEditing">
|
||||
<div i-solar:pen-2-line-duotone opacity-50 />
|
||||
</EditableEditTrigger>
|
||||
<div v-else class="flex gap-2">
|
||||
<EditableSubmitTrigger>
|
||||
<div i-solar:check-read-line-duotone opacity-50 />
|
||||
</EditableSubmitTrigger>
|
||||
</div>
|
||||
</EditableRoot>
|
||||
<div flex items-center gap-1 text="neutral-400 dark:neutral-600">
|
||||
<div i-solar:tag-horizontal-bold />
|
||||
<div>{{ mapFormatRenderer[model.format] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="hidden md:block" variant="secondary" @click="handlePick(model)">
|
||||
Pick
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="block md:hidden" @click="handleMobilePick()">
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import JSZip from 'jszip'
|
||||
import localforage from 'localforage'
|
||||
|
||||
import { Checkbox, FieldRange, Input } from '@proj-airi/ui'
|
||||
import { useFileDialog, useObjectUrl } from '@vueuse/core'
|
||||
import { Checkbox, FieldRange } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { Emotion } from '../../../../constants'
|
||||
import { useLive2d } from '../../../../stores/live2d'
|
||||
import { useSettings } from '../../../../stores/settings'
|
||||
import { Section } from '../../../Layouts'
|
||||
@@ -21,126 +15,106 @@ defineProps<{
|
||||
|
||||
defineEmits<{
|
||||
(e: 'extractColorsFromModel'): void
|
||||
(e: 'switchToVRM'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const modelFileDialog = useFileDialog({
|
||||
accept: 'application/zip',
|
||||
})
|
||||
|
||||
const settings = useSettings()
|
||||
const { live2dDisableFocus } = storeToRefs(settings)
|
||||
|
||||
const live2d = useLive2d()
|
||||
const {
|
||||
modelFile,
|
||||
motionMap,
|
||||
availableMotions,
|
||||
modelUrl,
|
||||
currentMotion,
|
||||
scale,
|
||||
position,
|
||||
} = storeToRefs(live2d)
|
||||
const localModelUrl = ref(modelUrl.value)
|
||||
|
||||
modelFileDialog.onChange((files) => {
|
||||
if (files && files.length > 0) {
|
||||
motionMap.value = {}
|
||||
modelFile.value = files[0]
|
||||
live2d.shouldUpdateView()
|
||||
}
|
||||
})
|
||||
// async function patchMotionMap(source: File, motionMap: Record<string, string>): Promise<File> {
|
||||
// if (!Object.keys(motionMap).length)
|
||||
// return source
|
||||
|
||||
async function patchMotionMap(source: File, motionMap: Record<string, string>): Promise<File> {
|
||||
if (!Object.keys(motionMap).length)
|
||||
return source
|
||||
// const jsZip = new JSZip()
|
||||
// const zip = await jsZip.loadAsync(source)
|
||||
// const fileName = Object.keys(zip.files).find(key => key.endsWith('model3.json'))
|
||||
// if (!fileName) {
|
||||
// throw new Error('model3.json not found')
|
||||
// }
|
||||
|
||||
const jsZip = new JSZip()
|
||||
const zip = await jsZip.loadAsync(source)
|
||||
const fileName = Object.keys(zip.files).find(key => key.endsWith('model3.json'))
|
||||
if (!fileName) {
|
||||
throw new Error('model3.json not found')
|
||||
}
|
||||
// const model3Json = await zip.file(fileName)!.async('string')
|
||||
// const model3JsonObject = JSON.parse(model3Json)
|
||||
|
||||
const model3Json = await zip.file(fileName)!.async('string')
|
||||
const model3JsonObject = JSON.parse(model3Json)
|
||||
// const motions: Record<string, { File: string }[]> = {}
|
||||
// Object.entries(motionMap).forEach(([key, value]) => {
|
||||
// if (motions[value]) {
|
||||
// motions[value].push({ File: key })
|
||||
// return
|
||||
// }
|
||||
// motions[value] = [{ File: key }]
|
||||
// })
|
||||
|
||||
const motions: Record<string, { File: string }[]> = {}
|
||||
Object.entries(motionMap).forEach(([key, value]) => {
|
||||
if (motions[value]) {
|
||||
motions[value].push({ File: key })
|
||||
return
|
||||
}
|
||||
motions[value] = [{ File: key }]
|
||||
})
|
||||
// model3JsonObject.FileReferences.Motions = motions
|
||||
|
||||
model3JsonObject.FileReferences.Motions = motions
|
||||
// zip.file(fileName, JSON.stringify(model3JsonObject, null, 2))
|
||||
// const zipBlob = await zip.generateAsync({ type: 'blob' })
|
||||
|
||||
zip.file(fileName, JSON.stringify(model3JsonObject, null, 2))
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' })
|
||||
// return new File([zipBlob], source.name, {
|
||||
// type: source.type,
|
||||
// lastModified: source.lastModified,
|
||||
// })
|
||||
// }
|
||||
|
||||
return new File([zipBlob], source.name, {
|
||||
type: source.type,
|
||||
lastModified: source.lastModified,
|
||||
})
|
||||
}
|
||||
// async function saveMotionMap() {
|
||||
// const fileFromIndexedDB = await localforage.getItem<File>('live2dModel')
|
||||
// if (!fileFromIndexedDB) {
|
||||
// return
|
||||
// }
|
||||
|
||||
async function saveMotionMap() {
|
||||
const fileFromIndexedDB = await localforage.getItem<File>('live2dModel')
|
||||
if (!fileFromIndexedDB) {
|
||||
return
|
||||
}
|
||||
|
||||
const patchedFile = await patchMotionMap(fileFromIndexedDB, motionMap.value)
|
||||
modelFile.value = patchedFile
|
||||
}
|
||||
|
||||
const exportObjectUrl = useObjectUrl(modelFile)
|
||||
// const patchedFile = await patchMotionMap(fileFromIndexedDB, motionMap.value)
|
||||
// modelFile.value = patchedFile
|
||||
// }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Section
|
||||
:title="t('settings.live2d.switch-to-vrm.title')"
|
||||
icon="i-solar:magic-stick-3-bold-duotone"
|
||||
:title="t('settings.live2d.scale-and-position.title')"
|
||||
icon="i-solar:scale-bold-duotone"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
size="sm"
|
||||
:expand="false"
|
||||
:expand="true"
|
||||
>
|
||||
<Button variant="secondary" @click="$emit('switchToVRM')">
|
||||
{{ t('settings.live2d.switch-to-vrm.change-to-vrm') }}
|
||||
</Button>
|
||||
</Section>
|
||||
<Section
|
||||
:title="t('settings.live2d.change-model.title')"
|
||||
icon="i-solar:magic-stick-3-bold-duotone"
|
||||
inner-class="text-sm"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
size="sm"
|
||||
:expand="false"
|
||||
>
|
||||
<Button variant="secondary" @click="modelFileDialog.open()">
|
||||
{{ t('settings.live2d.change-model.from-file') }}...
|
||||
</Button>
|
||||
<div flex items-center gap-2>
|
||||
<Input
|
||||
v-model="localModelUrl"
|
||||
class="flex-1"
|
||||
:placeholder="t('settings.live2d.change-model.from-url-placeholder')"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" @click="modelUrl = localModelUrl">
|
||||
{{ t('settings.live2d.change-model.from-url') }}
|
||||
</Button>
|
||||
</div>
|
||||
<FieldRange v-model="scale" as="div" :min="0.5" :max="2" :step="0.01" :label="t('settings.live2d.scale-and-position.scale')">
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.live2d.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="position.x" as="div" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.x')">
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.live2d.scale-and-position.x') }}</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => position.x = 0">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
<FieldRange v-model="position.y" as="div" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.y')">
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.live2d.scale-and-position.y') }}</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => position.y = 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.live2d.theme-color-from-model.title')"
|
||||
@@ -159,7 +133,7 @@ const exportObjectUrl = useObjectUrl(modelFile)
|
||||
{{ t('settings.live2d.theme-color-from-model.button-extract.title') }}
|
||||
</Button>
|
||||
</Section>
|
||||
<Section
|
||||
<!-- <Section
|
||||
v-if="modelFile"
|
||||
:title="t('settings.live2d.edit-motion-map.title')"
|
||||
icon="i-solar:face-scan-circle-bold-duotone"
|
||||
@@ -198,49 +172,7 @@ const exportObjectUrl = useObjectUrl(modelFile)
|
||||
>
|
||||
<Button w-full>Export</button>
|
||||
</a>
|
||||
</Section>
|
||||
<Section
|
||||
:title="t('settings.live2d.scale-and-position.title')"
|
||||
icon="i-solar:scale-bold-duotone"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
size="sm"
|
||||
:expand="false"
|
||||
>
|
||||
<FieldRange v-model="scale" as="div" :min="0.5" :max="2" :step="0.01" :label="t('settings.live2d.scale-and-position.scale')">
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.live2d.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="position.x" as="div" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.x')">
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.live2d.scale-and-position.x') }}</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => position.x = 0">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
<FieldRange v-model="position.y" as="div" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.y')">
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.live2d.scale-and-position.y') }}</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => position.y = 0">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
</Section>
|
||||
</Section> -->
|
||||
<Section
|
||||
:title="t('settings.live2d.focus.title')"
|
||||
icon="i-solar:eye-scan-bold-duotone"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@proj-airi/ui'
|
||||
import { useFileDialog, useObjectUrl } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -17,19 +15,12 @@ defineProps<{
|
||||
|
||||
defineEmits<{
|
||||
(e: 'extractColorsFromModel'): void
|
||||
(e: 'switchToLive2D'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const modelFileDialog = useFileDialog({
|
||||
accept: '.vrm',
|
||||
})
|
||||
|
||||
const vrm = useVRM()
|
||||
const {
|
||||
modelFile,
|
||||
modelUrl,
|
||||
modelSize,
|
||||
modelOffset,
|
||||
cameraFOV,
|
||||
@@ -53,52 +44,12 @@ const {
|
||||
|
||||
envSelect,
|
||||
} = storeToRefs(vrm)
|
||||
const { defaultModelUrl } = vrm // 普通字符串
|
||||
|
||||
const trackingOptions = computed(() => [
|
||||
{ value: 'camera', label: t('settings.vrm.scale-and-position.eye-tracking-mode.options.option.camera'), class: 'col-start-3' },
|
||||
{ value: 'mouse', label: t('settings.vrm.scale-and-position.eye-tracking-mode.options.option.mouse'), class: 'col-start-4' },
|
||||
{ value: 'none', label: t('settings.vrm.scale-and-position.eye-tracking-mode.options.option.disabled'), class: 'col-start-5' },
|
||||
])
|
||||
|
||||
modelFileDialog.onChange((files) => {
|
||||
if (files && files.length > 0) {
|
||||
modelFile.value = files[0]
|
||||
vrm.shouldUpdateView()
|
||||
}
|
||||
})
|
||||
|
||||
const urlFile = useObjectUrl(modelFile)
|
||||
const urlRef = computed({
|
||||
get: () => urlFile.value || modelUrl.value || '',
|
||||
set: (value) => {
|
||||
modelUrl.value = value
|
||||
},
|
||||
})
|
||||
|
||||
async function handleUrlLoad() {
|
||||
const raw = (modelUrl.value || '').trim()
|
||||
// If empty input, reset to default
|
||||
if (!raw) {
|
||||
modelFile.value = null
|
||||
urlRef.value = defaultModelUrl
|
||||
return
|
||||
}
|
||||
// 3) parse URL
|
||||
let parsedUrl: URL
|
||||
try {
|
||||
parsedUrl = new URL(raw, window.location.origin)
|
||||
}
|
||||
catch {
|
||||
console.warn('Illegal URL input')
|
||||
return
|
||||
}
|
||||
if (!['http:', 'https:', 'blob:', 'data:'].includes(parsedUrl.protocol))
|
||||
return
|
||||
|
||||
modelUrl.value = parsedUrl.href
|
||||
}
|
||||
|
||||
// switch between hemisphere light and sky box
|
||||
const tabList = [
|
||||
{
|
||||
@@ -130,9 +81,6 @@ const tabList = [
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
>
|
||||
<Button variant="secondary" size="sm" @click="$emit('switchToLive2D')">
|
||||
{{ t('settings.vrm.switch-to-vrm.change-to-vrm') }}
|
||||
</Button>
|
||||
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
|
||||
<Button variant="secondary" @click="$emit('extractColorsFromModel')">
|
||||
{{ t('settings.vrm.theme-color-from-model.button-extract.title') }}
|
||||
@@ -274,25 +222,6 @@ const tabList = [
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
variant="secondary" @click=" () => {
|
||||
modelFileDialog.reset()
|
||||
modelFileDialog.open()
|
||||
}"
|
||||
>
|
||||
{{ t('settings.vrm.change-model.from-file') }}...
|
||||
</Button>
|
||||
<div flex items-center gap-2>
|
||||
<Input
|
||||
v-model="urlRef"
|
||||
class="flex-1"
|
||||
:placeholder="t('settings.vrm.change-model.from-url-placeholder')"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" @click="handleUrlLoad">
|
||||
{{ t('settings.vrm.change-model.from-url') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Callout :label="t('settings.vrm.scale-and-position.model-info-title')">
|
||||
<div>
|
||||
<div class="text-sm text-neutral-600 space-y-1 dark:text-neutral-400">
|
||||
|
||||
@@ -1,87 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import type { DisplayModel } from '../../../../stores/display-models'
|
||||
|
||||
import { useMouse } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import Callout from '../../../Layouts/Callout.vue'
|
||||
import Button from '../../../Misc/Button.vue'
|
||||
import Live2DScene from '../../../Scenes/Live2D.vue'
|
||||
import VRMScene from '../../../Scenes/VRM.vue'
|
||||
import ModelManagerDialog from '../../Dialogs/model-selector/model-selector-dialog.vue'
|
||||
import Live2D from './Live2D.vue'
|
||||
import VRM from './VRM.vue'
|
||||
|
||||
import { useLive2d } from '../../../../stores/live2d'
|
||||
import { useSettings } from '../../../../stores/settings'
|
||||
import { useVRM } from '../../../../stores/vrm'
|
||||
|
||||
const props = defineProps<{
|
||||
palette: string[]
|
||||
settingsClass?: string | string[]
|
||||
|
||||
live2dSceneClass?: string | string[]
|
||||
live2dSettingsClass?: string | string[]
|
||||
vrmSceneClass?: string | string[]
|
||||
vrmSettingsClass?: string | string[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'extractColorsFromModel'): void
|
||||
}>()
|
||||
|
||||
const positionCursor = useMouse()
|
||||
const selectedModel = ref<DisplayModel | undefined>()
|
||||
|
||||
const { stageView, live2dDisableFocus } = storeToRefs(useSettings())
|
||||
const { modelFile: live2dModelFile, modelUrl: live2dModelUrl } = storeToRefs(useLive2d())
|
||||
const { modelFile: vrmModelFile, modelUrl: vrmModelUrl } = storeToRefs(useVRM())
|
||||
const positionCursor = useMouse()
|
||||
const settingsStore = useSettings()
|
||||
const { live2dDisableFocus, stageModelSelectedUrl, stageModelSelected, stageModelRenderer } = storeToRefs(settingsStore)
|
||||
|
||||
watch(selectedModel, () => {
|
||||
stageModelSelected.value = selectedModel.value?.id
|
||||
settingsStore.updateStageModel()
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
flex="~ col gap-2" z-10 overflow-y-scroll p-2 :class="[
|
||||
...(props.settingsClass
|
||||
? (typeof props.settingsClass === 'string' ? [props.settingsClass] : props.settingsClass)
|
||||
: []),
|
||||
]"
|
||||
>
|
||||
<Callout label="We support both 2D and 3D models">
|
||||
<p>
|
||||
Click <strong>Select Model</strong> to import different formats of
|
||||
models into catalog, currently, <code>.zip</code> (Live2D) and <code>.vrm</code> (VRM) are supported.
|
||||
</p>
|
||||
<p>
|
||||
Neuro-sama uses 2D model driven by Live2D Inc. developed framework.
|
||||
While Grok Ani (first female character announced in Grok Companion)
|
||||
uses 3D model that is driven by VRM / MMD open formats.
|
||||
</p>
|
||||
</Callout>
|
||||
<ModelManagerDialog v-model="selectedModel">
|
||||
<Button variant="secondary">
|
||||
Select Model
|
||||
</Button>
|
||||
</ModelManagerDialog>
|
||||
<Live2D v-if="stageModelRenderer === 'live2d'" :palette="palette" @extract-colors-from-model="$emit('extractColorsFromModel')" />
|
||||
<VRM v-if="stageModelRenderer === 'vrm'" :palette="palette" @extract-colors-from-model="$emit('extractColorsFromModel')" />
|
||||
</div>
|
||||
<!-- Live2D component for 2D stage view -->
|
||||
<template v-if="stageView === '2d'">
|
||||
<div
|
||||
:class="[
|
||||
...(props.live2dSceneClass
|
||||
? (typeof props.live2dSceneClass === 'string' ? [props.live2dSceneClass] : props.live2dSceneClass)
|
||||
: []),
|
||||
]"
|
||||
>
|
||||
<template v-if="stageModelRenderer === 'live2d'">
|
||||
<div :class="[...(props.live2dSceneClass ? (typeof props.live2dSceneClass === 'string' ? [props.live2dSceneClass] : props.live2dSceneClass) : [])]">
|
||||
<Live2DScene
|
||||
:focus-at="{
|
||||
x: positionCursor.x.value,
|
||||
y: positionCursor.y.value,
|
||||
}"
|
||||
:model-src="live2dModelUrl"
|
||||
:model-file="live2dModelFile"
|
||||
:focus-at="{ x: positionCursor.x.value, y: positionCursor.y.value }"
|
||||
:model-src="stageModelSelectedUrl"
|
||||
:disable-focus-at="live2dDisableFocus"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
flex="~ col gap-2" :class="[
|
||||
...(props.live2dSettingsClass
|
||||
? (typeof props.live2dSettingsClass === 'string' ? [props.live2dSettingsClass] : props.live2dSettingsClass)
|
||||
: []),
|
||||
]"
|
||||
>
|
||||
<Live2D :palette="palette" @extract-colors-from-model="$emit('extractColorsFromModel')" @switch-to-v-r-m="stageView = '3d'" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- VRM component for 3D stage view -->
|
||||
<template v-if="stageView === '3d'">
|
||||
<div
|
||||
:class="[
|
||||
...(props.vrmSceneClass
|
||||
? (typeof props.vrmSceneClass === 'string' ? [props.vrmSceneClass] : props.vrmSceneClass)
|
||||
: []),
|
||||
]"
|
||||
>
|
||||
<VRMScene :model-src="vrmModelUrl" :model-file="vrmModelFile" />
|
||||
</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>
|
||||
<template v-if="stageModelRenderer === 'vrm'">
|
||||
<div :class="[...(props.vrmSceneClass ? (typeof props.vrmSceneClass === 'string' ? [props.vrmSceneClass] : props.vrmSceneClass) : [])]">
|
||||
<VRMScene :model-src="stageModelSelectedUrl" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,6 @@ import '../../utils/live2d-zip-loader'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
modelSrc?: string
|
||||
modelFile?: File | null
|
||||
|
||||
paused?: boolean
|
||||
mouthOpenSize?: number
|
||||
@@ -35,7 +34,6 @@ withDefaults(defineProps<{
|
||||
>
|
||||
<Live2DModel
|
||||
:model-src="modelSrc"
|
||||
:model-file="modelFile"
|
||||
:app="app"
|
||||
:mouth-open-size="mouthOpenSize"
|
||||
:width="width"
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import type { Application } from '@pixi/app'
|
||||
import type { Cubism4InternalModel, InternalModel } from 'pixi-live2d-display/cubism4'
|
||||
|
||||
import localforage from 'localforage'
|
||||
|
||||
import { breakpointsTailwind, until, useBreakpoints, useDark, useDebounceFn, useObjectUrl } from '@vueuse/core'
|
||||
import { breakpointsTailwind, until, useBreakpoints, useDark, useDebounceFn } from '@vueuse/core'
|
||||
import { formatHex } from 'culori'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { DropShadowFilter } from 'pixi-filters'
|
||||
@@ -25,7 +23,6 @@ type PixiLive2DInternalModel = InternalModel & {
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelSrc?: string
|
||||
modelFile?: File | null
|
||||
|
||||
app?: Application
|
||||
mouthOpenSize?: number
|
||||
@@ -67,17 +64,6 @@ function parsePropsOffset() {
|
||||
}
|
||||
|
||||
const modelSrcRef = toRef(() => props.modelSrc)
|
||||
const modelFileRef = toRef(() => props.modelFile)
|
||||
const modelFileSrc = useObjectUrl(modelFileRef)
|
||||
const modelSrcNormalized = computed(() => {
|
||||
if (modelFileSrc.value)
|
||||
return modelFileSrc.value
|
||||
|
||||
if (modelSrcRef.value)
|
||||
return modelSrcRef.value
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const modelLoading = ref(false)
|
||||
|
||||
@@ -127,7 +113,6 @@ function setScaleAndPosition() {
|
||||
}
|
||||
|
||||
const {
|
||||
modelFile,
|
||||
currentMotion,
|
||||
availableMotions,
|
||||
motionMap,
|
||||
@@ -155,7 +140,7 @@ async function loadModel() {
|
||||
model.value.destroy()
|
||||
model.value = undefined
|
||||
}
|
||||
if (!modelSrcNormalized.value) {
|
||||
if (!modelSrcRef.value) {
|
||||
console.warn('No Live2D model source provided.')
|
||||
modelLoading.value = false
|
||||
return
|
||||
@@ -163,11 +148,13 @@ async function loadModel() {
|
||||
|
||||
try {
|
||||
const modelInstance = new Live2DModel<PixiLive2DInternalModel>()
|
||||
if (modelFile.value) {
|
||||
await Live2DFactory.setupLive2DModel(modelInstance, [modelFile.value], { autoInteract: false })
|
||||
if (modelSrcRef.value.startsWith('blob:')) {
|
||||
const res = await fetch(modelSrcRef.value)
|
||||
const blob = await res.blob()
|
||||
await Live2DFactory.setupLive2DModel(modelInstance, [blob], { autoInteract: false })
|
||||
}
|
||||
else {
|
||||
await Live2DFactory.setupLive2DModel(modelInstance, modelSrcNormalized.value, { autoInteract: false })
|
||||
await Live2DFactory.setupLive2DModel(modelInstance, modelSrcRef.value, { autoInteract: false })
|
||||
}
|
||||
|
||||
availableMotions.value.forEach((motion) => {
|
||||
@@ -268,11 +255,6 @@ async function loadModel() {
|
||||
localCurrentMotion.value = { group, index }
|
||||
})
|
||||
|
||||
// save to indexdb
|
||||
if (modelFile.value) {
|
||||
await localforage.setItem('live2dModel', modelFile.value)
|
||||
}
|
||||
|
||||
emits('modelLoaded')
|
||||
}
|
||||
finally {
|
||||
@@ -299,7 +281,7 @@ function updateDropShadowFilter() {
|
||||
}
|
||||
|
||||
watch([() => props.width, () => props.height], () => handleResize())
|
||||
watch(modelSrcNormalized, async () => await loadModel(), { immediate: true })
|
||||
watch(modelSrcRef, async () => await loadModel(), { immediate: true })
|
||||
watch(dark, updateDropShadowFilter, { immediate: true })
|
||||
watch([model, themeColorsHue], updateDropShadowFilter)
|
||||
watch(offset, setScaleAndPosition)
|
||||
|
||||
@@ -27,7 +27,6 @@ import { useLive2d } from '../../stores/live2d'
|
||||
import { useSpeechStore } from '../../stores/modules/speech'
|
||||
import { useProvidersStore } from '../../stores/providers'
|
||||
import { useSettings } from '../../stores/settings'
|
||||
import { useVRM } from '../../stores/vrm'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
paused?: boolean
|
||||
@@ -42,13 +41,11 @@ const db = ref<DuckDBWasmDrizzleDatabase>()
|
||||
|
||||
const vrmViewerRef = ref<{ setExpression: (expression: string) => void }>()
|
||||
|
||||
const { stageView, stageViewControlsEnabled, live2dDisableFocus } = storeToRefs(useSettings())
|
||||
const { stageView, stageViewControlsEnabled, live2dDisableFocus, stageModelSelectedUrl } = storeToRefs(useSettings())
|
||||
const { mouthOpenSize } = storeToRefs(useSpeakingStore())
|
||||
const { audioContext, calculateVolume } = useAudioContext()
|
||||
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd, onAssistantResponseEnd } = useChatStore()
|
||||
const providersStore = useProvidersStore()
|
||||
const { modelFile, modelUrl } = storeToRefs(useLive2d())
|
||||
const { modelFile: vrmModelFile, modelUrl: vrmModelUrl } = storeToRefs(useVRM())
|
||||
|
||||
const audioAnalyser = ref<AnalyserNode>()
|
||||
const nowSpeaking = ref(false)
|
||||
@@ -140,14 +137,14 @@ const { currentMotion } = storeToRefs(useLive2d())
|
||||
const emotionsQueue = useQueue<Emotion>({
|
||||
handlers: [
|
||||
async (ctx) => {
|
||||
if (stageView.value === '3d') {
|
||||
if (stageView.value === 'vrm') {
|
||||
const value = EMOTION_VRMExpressionName_value[ctx.data]
|
||||
if (!value)
|
||||
return
|
||||
|
||||
await vrmViewerRef.value!.setExpression(value)
|
||||
}
|
||||
else if (stageView.value === '2d') {
|
||||
else if (stageView.value === 'live2d') {
|
||||
currentMotion.value = { group: EMOTION_EmotionMotionName_value[ctx.data] }
|
||||
}
|
||||
},
|
||||
@@ -232,10 +229,9 @@ onMounted(async () => {
|
||||
<div relative>
|
||||
<div h-full w-full>
|
||||
<Live2DScene
|
||||
v-if="stageView === '2d'"
|
||||
v-if="stageView === 'live2d'"
|
||||
min-w="50% <lg:full" min-h="100 sm:100" h-full w-full flex-1
|
||||
:model-src="modelUrl"
|
||||
:model-file="modelFile"
|
||||
:model-src="stageModelSelectedUrl"
|
||||
:focus-at="focusAt"
|
||||
:mouth-open-size="mouthOpenSize"
|
||||
:paused="paused"
|
||||
@@ -245,10 +241,9 @@ onMounted(async () => {
|
||||
:disable-focus-at="live2dDisableFocus"
|
||||
/>
|
||||
<VRMScene
|
||||
v-else-if="stageView === '3d'"
|
||||
v-else-if="stageView === 'vrm'"
|
||||
ref="vrmViewerRef"
|
||||
:model-src="vrmModelUrl"
|
||||
:model-file="vrmModelFile"
|
||||
:model-src="stageModelSelectedUrl"
|
||||
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"
|
||||
|
||||
@@ -15,7 +15,6 @@ import { OrbitControls, VRMModel } from '../Scenes'
|
||||
|
||||
const props = defineProps<{
|
||||
modelSrc?: string
|
||||
modelFile?: File | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -292,7 +291,6 @@ defineExpose({
|
||||
<VRMModel
|
||||
ref="modelRef"
|
||||
:model-src="props.modelSrc"
|
||||
:model-file="props.modelFile"
|
||||
idle-animation="/assets/vrm/animations/idle_loop.vrma"
|
||||
:paused="false"
|
||||
@load-model-progress="(val) => emit('loadModelProgress', val)"
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AnimationClip, Group } from 'three'
|
||||
|
||||
import { VRMUtils } from '@pixiv/three-vrm'
|
||||
import { useLoop, useTresContext } from '@tresjs/core'
|
||||
import { until, useObjectUrl } from '@vueuse/core'
|
||||
import { until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { AnimationMixer, MathUtils, Mesh, MeshPhysicalMaterial, MeshStandardMaterial, Quaternion, Vector3, VectorKeyframeTrack } from 'three'
|
||||
import { computed, onMounted, onUnmounted, ref, toRef, watch } from 'vue'
|
||||
@@ -16,7 +16,6 @@ import { useVRM } from '../../../stores/vrm'
|
||||
|
||||
const props = defineProps<{
|
||||
modelSrc?: string
|
||||
modelFile?: File | null
|
||||
|
||||
idleAnimation: string
|
||||
loadAnimations?: string[]
|
||||
@@ -31,20 +30,9 @@ const emit = defineEmits<{
|
||||
|
||||
let disposeBeforeRenderLoop: (() => void | undefined)
|
||||
|
||||
const modelCreating = ref(false)
|
||||
const modelLoading = ref(false)
|
||||
const modelLoaded = ref(false)
|
||||
const modelSrcRef = toRef(() => props.modelSrc)
|
||||
const modelFileRef = toRef(() => props.modelFile)
|
||||
const modelFileSrc = useObjectUrl(modelFileRef)
|
||||
const modelSrcNormalized = computed(() => {
|
||||
if (modelFileSrc.value)
|
||||
return modelFileSrc.value
|
||||
|
||||
if (modelSrcRef.value)
|
||||
return modelSrcRef.value
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const vrm = ref<VRMCore>()
|
||||
const vrmAnimationMixer = ref<AnimationMixer>()
|
||||
@@ -68,8 +56,8 @@ const vrmGroup = ref<Group>()
|
||||
const idleEyeSaccades = useIdleEyeSaccades()
|
||||
|
||||
async function loadModel() {
|
||||
await until(modelCreating).not.toBeTruthy()
|
||||
modelCreating.value = true
|
||||
await until(modelLoading).not.toBeTruthy()
|
||||
modelLoading.value = true
|
||||
modelLoaded.value = false
|
||||
|
||||
try {
|
||||
@@ -80,9 +68,12 @@ async function loadModel() {
|
||||
if (vrm.value) {
|
||||
componentCleanUp()
|
||||
}
|
||||
if (!modelSrcRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const _vrmInfo = await loadVrm(modelSrcNormalized.value, {
|
||||
const _vrmInfo = await loadVrm(modelSrcRef.value, {
|
||||
scene: scene.value,
|
||||
lookAt: true,
|
||||
onProgress: progress => emit('loadModelProgress', Number((100 * progress.loaded / progress.total).toFixed(2))),
|
||||
@@ -255,7 +246,7 @@ async function loadModel() {
|
||||
console.error(err)
|
||||
}
|
||||
finally {
|
||||
modelCreating.value = false
|
||||
modelLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +267,7 @@ watch(modelRotationY, (newRotationY) => {
|
||||
})
|
||||
|
||||
// watch if the model needs to be reloaded
|
||||
watch(modelSrcNormalized, (newSrc, oldSrc) => {
|
||||
watch(modelSrcRef, (newSrc, oldSrc) => {
|
||||
if (newSrc !== oldSrc) {
|
||||
loadModel()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { FieldRange } from '@proj-airi/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import PoppinMarkdown from './PoppinMarkdown.web.vue'
|
||||
|
||||
import {
|
||||
createCutePopupAnimator,
|
||||
createFadeAnimator,
|
||||
createFloatAnimator,
|
||||
createPopupAnimator,
|
||||
createStackAnimator,
|
||||
} from './animators'
|
||||
|
||||
const duration = ref(750)
|
||||
|
||||
// Sample markdown content
|
||||
const markdownText = ref(`# Hello World
|
||||
|
||||
This is **bold text** and *italic text*.
|
||||
|
||||
Here's a list:
|
||||
- Item 1
|
||||
- Item 2
|
||||
- Item 3
|
||||
|
||||
\`\`\`javascript
|
||||
console.log('Hello, world!');
|
||||
\`\`\`
|
||||
|
||||
> This is a blockquote with some **bold** content.
|
||||
|
||||
And here's a [link](https://example.com) and some inline \`code\`.`)
|
||||
|
||||
const chatMarkdown = ref(`Hi! I can help you with **markdown**. Here are some examples:
|
||||
|
||||
- *Italics* and **bold** text
|
||||
- \`inline code\` snippets
|
||||
- Even [links](https://example.com)
|
||||
|
||||
\`\`\`typescript
|
||||
function greet(name: string) {
|
||||
console.log(\`Hello, \${name}!\`);
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
What would you like to know?`)
|
||||
|
||||
function createMarkdownStream(text: string) {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const bytes = new TextEncoder().encode(text)
|
||||
let index = 0
|
||||
const interval = setInterval(() => {
|
||||
if (index < bytes.length) {
|
||||
controller.enqueue(bytes.subarray(index, index + 1))
|
||||
index++
|
||||
}
|
||||
else {
|
||||
clearInterval(interval)
|
||||
controller.close()
|
||||
}
|
||||
}, 30 + 50 * Math.random()) // Faster for better demo
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const streamingMarkdown = createMarkdownStream(`# Streaming Markdown Demo
|
||||
|
||||
This text is being **streamed** character by character!
|
||||
|
||||
## Features
|
||||
|
||||
- Real-time *markdown* rendering
|
||||
- **Character-by-character** animation
|
||||
- Support for \`code\` and other elements
|
||||
|
||||
\`\`\`javascript
|
||||
// Code blocks appear as complete units
|
||||
const message = "Hello from streaming markdown!";
|
||||
console.log(message);
|
||||
\`\`\`
|
||||
|
||||
> Blockquotes work too with **mixed** formatting.
|
||||
|
||||
Pretty cool, right?`)
|
||||
|
||||
const chatStreamingMarkdown = createMarkdownStream(`🤖 **AI Assistant**: Hello! I can help you with various tasks.
|
||||
|
||||
Here's what I can do:
|
||||
- Answer questions about **programming**
|
||||
- Help with \`code debugging\`
|
||||
- Explain *technical concepts*
|
||||
|
||||
\`\`\`python
|
||||
# Example: Python function
|
||||
def greet_user(name):
|
||||
return f"Hello, {name}! How can I help?"
|
||||
|
||||
print(greet_user("Developer"))
|
||||
\`\`\`
|
||||
|
||||
> 💡 **Tip**: I stream my responses in real-time for a better experience!
|
||||
|
||||
What would you like to work on today?`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="PoppinMarkdown"
|
||||
group="widgets"
|
||||
:layout="{ type: 'grid', width: '100%' }"
|
||||
>
|
||||
<template #controls>
|
||||
<div px-2>
|
||||
<FieldRange
|
||||
v-model.number="duration"
|
||||
:min="50"
|
||||
:max="3000"
|
||||
:step="50"
|
||||
label="Animation Duration"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Variant
|
||||
id="static-markdown"
|
||||
title="Static Markdown"
|
||||
>
|
||||
<div h-auto max-w-2xl w-full p-4>
|
||||
<PoppinMarkdown
|
||||
:text="markdownText"
|
||||
:animator="createFadeAnimator({ duration })"
|
||||
class="prose-sm prose dark:prose-invert"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="streaming-markdown"
|
||||
title="Streaming Markdown"
|
||||
>
|
||||
<div h-auto max-w-2xl w-full p-4>
|
||||
<PoppinMarkdown
|
||||
:text="streamingMarkdown"
|
||||
:animator="createFadeAnimator({ duration: 100 })"
|
||||
class="prose-sm prose dark:prose-invert"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="chat-style"
|
||||
title="Chat Style"
|
||||
>
|
||||
<div h-auto max-w-2xl w-full p-4>
|
||||
<div
|
||||
class="border-2 border-primary-200/50 rounded-lg border-solid bg-primary-50/50 p-4 dark:border-primary-500/50 dark:bg-primary-950/70"
|
||||
>
|
||||
<PoppinMarkdown
|
||||
:text="chatMarkdown"
|
||||
:animator="createFadeAnimator({ duration: 150 })"
|
||||
class="prose-sm prose prose-primary dark:prose-invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="streaming-chat"
|
||||
title="Streaming Chat Response"
|
||||
>
|
||||
<div h-auto max-w-2xl w-full p-4>
|
||||
<div
|
||||
class="border-2 border-primary-200/50 rounded-lg border-solid bg-primary-50/50 p-4 dark:border-primary-500/50 dark:bg-primary-950/70"
|
||||
>
|
||||
<PoppinMarkdown
|
||||
:text="chatStreamingMarkdown"
|
||||
:animator="createFadeAnimator({ duration: 80 })"
|
||||
class="prose-sm prose prose-primary dark:prose-invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="popup-animation"
|
||||
title="Popup Animation"
|
||||
>
|
||||
<div h-auto max-w-2xl w-full p-4>
|
||||
<PoppinMarkdown
|
||||
:text="markdownText"
|
||||
:animator="createPopupAnimator({ duration })"
|
||||
class="prose-sm prose dark:prose-invert"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="float-animation"
|
||||
title="Float Animation"
|
||||
>
|
||||
<div h-auto max-w-2xl w-full p-4>
|
||||
<PoppinMarkdown
|
||||
:text="markdownText"
|
||||
:animator="createFloatAnimator({ duration })"
|
||||
class="prose-sm prose dark:prose-invert"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="cute-popup"
|
||||
title="Cute Popup"
|
||||
>
|
||||
<div h-auto max-w-2xl w-full p-4>
|
||||
<PoppinMarkdown
|
||||
:text="chatMarkdown"
|
||||
:animator="createCutePopupAnimator({ duration })"
|
||||
class="prose-sm prose dark:prose-invert"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="stack-animation"
|
||||
title="Stack Animation"
|
||||
>
|
||||
<div h-auto max-w-2xl w-full p-4>
|
||||
<PoppinMarkdown
|
||||
:text="markdownText"
|
||||
:animator="createStackAnimator({ duration })"
|
||||
class="prose-sm prose dark:prose-invert"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="no-markdown"
|
||||
title="Plain Text Mode"
|
||||
>
|
||||
<div h-auto max-w-2xl w-full p-4>
|
||||
<PoppinMarkdown
|
||||
:text="markdownText"
|
||||
:markdown="false"
|
||||
:animator="createFadeAnimator({ duration })"
|
||||
class="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,212 @@
|
||||
<!-- PoppinMarkdown - PoppinText with markdown support -->
|
||||
<script setup lang="ts">
|
||||
import type { Element, Root } from 'hast'
|
||||
|
||||
import type { Animator } from './animators'
|
||||
|
||||
import RemarkParse from 'remark-parse'
|
||||
import RemarkRehype from 'remark-rehype'
|
||||
|
||||
import { readGraphemeClusters } from 'clustr'
|
||||
import { unified } from 'unified'
|
||||
import { computed, defineComponent, h, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/**
|
||||
* A plain string or a ReadableStream of bytes from text in UTF-8 encoding.
|
||||
* If a stream is provided, the stream **SHOULD NOT** be reused.
|
||||
*/
|
||||
text?: string | ReadableStream<Uint8Array>
|
||||
textClass?: string | string[]
|
||||
animator?: Animator
|
||||
/**
|
||||
* Whether to process the accumulated text as markdown
|
||||
* @default true
|
||||
*/
|
||||
markdown?: boolean
|
||||
}>(), {
|
||||
markdown: true,
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'textSplit', grapheme: string): void
|
||||
(e: 'markdownUpdated', html: string): void
|
||||
}>()
|
||||
|
||||
const targets = ref<string[]>([])
|
||||
const abortController = shallowRef<AbortController>()
|
||||
const segmenter = new Intl.Segmenter('und', { granularity: 'grapheme' })
|
||||
|
||||
// Accumulate all text content
|
||||
const accumulatedText = ref('')
|
||||
const animatedCharCount = ref(0)
|
||||
|
||||
// Create markdown processor
|
||||
const processor = unified()
|
||||
.use(RemarkParse)
|
||||
.use(RemarkRehype)
|
||||
|
||||
// Convert HAST to Vue render function
|
||||
function hastToVNode(node: any, animator?: Animator): any {
|
||||
if (node.type === 'text') {
|
||||
// Text node - split into animated characters
|
||||
const textContent = node.value.trim()
|
||||
if (!textContent)
|
||||
return null
|
||||
|
||||
const characters = [...segmenter.segment(textContent)].map(seg => seg.segment)
|
||||
const currentAnimatedCount = animatedCharCount.value
|
||||
|
||||
return characters.map((char, index) => {
|
||||
const globalIndex = currentAnimatedCount + index
|
||||
|
||||
return h('span', {
|
||||
key: `char-${globalIndex}`,
|
||||
class: 'inline-block poppin-char',
|
||||
onVnodeMounted: (vnode: any) => {
|
||||
if (animator && vnode.el) {
|
||||
setTimeout(() => animator([vnode.el]), 0)
|
||||
}
|
||||
},
|
||||
}, char)
|
||||
})
|
||||
}
|
||||
|
||||
if (node.type === 'element') {
|
||||
const element = node as Element
|
||||
const { tagName, properties = {}, children = [] } = element
|
||||
|
||||
// Check if this is a non-text element that should animate as a unit
|
||||
const isNonTextElement = ['img', 'code', 'pre', 'hr', 'br'].includes(tagName)
|
||||
|| (properties.className
|
||||
&& (properties.className as string[]).includes('code-block'))
|
||||
|
||||
if (isNonTextElement) {
|
||||
return h(tagName, {
|
||||
...properties,
|
||||
key: `element-${tagName}-${animatedCharCount.value}`,
|
||||
class: [properties.className, 'poppin-element'],
|
||||
onVnodeMounted: (vnode: any) => {
|
||||
if (animator && vnode.el) {
|
||||
setTimeout(() => animator([vnode.el]), 0)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Regular element - recursively process children
|
||||
const childVNodes = children
|
||||
.map((child: any) => hastToVNode(child, animator))
|
||||
.flat()
|
||||
.filter(Boolean)
|
||||
|
||||
return h(tagName, properties, childVNodes)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse markdown to HAST and create Vue component
|
||||
const MarkdownComponent = computed(() => {
|
||||
if (!props.markdown || !accumulatedText.value) {
|
||||
// Fallback to character animation
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
return defineComponent({
|
||||
setup() {
|
||||
return () => targets.value.map((char, index) =>
|
||||
h('span', {
|
||||
key: `fallback-${index}`,
|
||||
class: 'inline-block color-primary-400 dark:color-primary-100',
|
||||
onVnodeMounted: (vnode: any) => {
|
||||
if (props.animator && vnode.el) {
|
||||
setTimeout(() => props.animator?.([vnode.el]), 0)
|
||||
}
|
||||
},
|
||||
}, char),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const ast = processor.parse(accumulatedText.value)
|
||||
const hast = processor.runSync(ast) as Root
|
||||
|
||||
// eslint-disable-next-line vue/one-component-per-file
|
||||
return defineComponent({
|
||||
setup() {
|
||||
return () => h('div', {
|
||||
class: ['markdown-content', props.textClass].flat().filter(Boolean),
|
||||
}, hast.children.map((child: any) => hastToVNode(child, props.animator)))
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Markdown parsing failed:', error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// Update character count when streaming
|
||||
watch(accumulatedText, (newText, oldText) => {
|
||||
if (newText.length > (oldText?.length || 0)) {
|
||||
// Only update count when new characters are added
|
||||
const addedChars = newText.slice(oldText?.length || 0)
|
||||
const addedCharCount = [...segmenter.segment(addedChars)].length
|
||||
animatedCharCount.value += addedCharCount
|
||||
}
|
||||
else if (newText.length < (oldText?.length || 0)) {
|
||||
// Text was reset, reset count
|
||||
animatedCharCount.value = 0
|
||||
}
|
||||
|
||||
emits('markdownUpdated', newText)
|
||||
})
|
||||
|
||||
// Reset animation count when text prop changes
|
||||
watch(() => props.text, () => {
|
||||
animatedCharCount.value = 0
|
||||
}, { flush: 'pre' })
|
||||
|
||||
watch(() => props.text, async (text) => {
|
||||
if (!text) {
|
||||
accumulatedText.value = ''
|
||||
targets.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof text === 'string') {
|
||||
accumulatedText.value = text
|
||||
targets.value = [...segmenter.segment(text)].map(seg => seg.segment)
|
||||
}
|
||||
else {
|
||||
abortController.value?.abort()
|
||||
abortController.value = new AbortController()
|
||||
try {
|
||||
targets.value = []
|
||||
accumulatedText.value = ''
|
||||
|
||||
for await (const cluster of readGraphemeClusters(text.getReader(), { signal: abortController.value.signal })) {
|
||||
accumulatedText.value += cluster
|
||||
targets.value.push(cluster)
|
||||
emits('textSplit', cluster)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error && error.message === 'Aborted') {
|
||||
console.warn('Text reading aborted')
|
||||
}
|
||||
else {
|
||||
console.error('Error reading text:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="MarkdownComponent"
|
||||
:key="accumulatedText.length"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,5 +1,111 @@
|
||||
import localforage from 'localforage'
|
||||
|
||||
import { until } from '@vueuse/core'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useDisplayModelsStore = defineStore('display-models', {
|
||||
export enum DisplayModelFormat {
|
||||
Live2dZip = 'live2d-zip',
|
||||
Live2dDirectory = 'live2d-directory',
|
||||
VRM = 'vrm',
|
||||
PMXZip = 'pmx-zip',
|
||||
PMXDirectory = 'pmx-directory',
|
||||
PMD = 'pmd',
|
||||
}
|
||||
|
||||
export type DisplayModel
|
||||
= | DisplayModelFile
|
||||
| DisplayModelURL
|
||||
|
||||
export interface DisplayModelFile {
|
||||
id: string
|
||||
format: DisplayModelFormat
|
||||
type: 'file'
|
||||
file: File
|
||||
name: string
|
||||
previewImage?: string
|
||||
}
|
||||
|
||||
export interface DisplayModelURL {
|
||||
id: string
|
||||
format: DisplayModelFormat
|
||||
type: 'url'
|
||||
url: string
|
||||
name: string
|
||||
previewImage?: string
|
||||
}
|
||||
|
||||
const displayModelsPresets: DisplayModel[] = [
|
||||
{ id: 'preset-live2d-1', format: DisplayModelFormat.Live2dZip, type: 'url', url: '/assets/live2d/models/hiyori_pro_zh.zip', name: 'Hiyori (Pro)', previewImage: '/assets/live2d/models/hiyori_free_zh/avatar.png' },
|
||||
{ id: 'preset-live2d-2', format: DisplayModelFormat.Live2dZip, type: 'url', url: '/assets/live2d/models/hiyori_free_zh.zip', name: 'Hiyori (Free)', previewImage: '/assets/live2d/models/hiyori_free_zh/avatar.png' },
|
||||
{ id: 'preset-live2d-3', format: DisplayModelFormat.Live2dZip, type: 'url', url: '/assets/live2d/models/hiyori_free_zh.zip', name: 'Hiyori (Free)' },
|
||||
{ id: 'preset-live2d-4', format: DisplayModelFormat.Live2dZip, type: 'url', url: '/assets/live2d/models/hiyori_free_zh.zip', name: 'Hiyori (Free)', previewImage: '/assets/live2d/models/hiyori_free_zh/avatar.png' },
|
||||
]
|
||||
|
||||
export const useDisplayModelsStore = defineStore('display-models', () => {
|
||||
const displayModels = ref<DisplayModel[]>([])
|
||||
|
||||
const displayModelsFromIndexedDBLoading = ref(false)
|
||||
|
||||
async function loadDisplayModelsFromIndexedDB() {
|
||||
await until(displayModelsFromIndexedDBLoading).toBe(false)
|
||||
|
||||
displayModelsFromIndexedDBLoading.value = true
|
||||
const models = [...displayModelsPresets]
|
||||
|
||||
try {
|
||||
await localforage.iterate<{ format: DisplayModelFormat, file: File }, void>((val, key) => {
|
||||
if (key.startsWith('display-model-')) {
|
||||
models.push({ id: key, format: val.format, type: 'file', file: val.file, name: val.file.name })
|
||||
}
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
|
||||
displayModels.value = models
|
||||
displayModelsFromIndexedDBLoading.value = false
|
||||
}
|
||||
|
||||
async function getDisplayModel(id: string) {
|
||||
await until(displayModelsFromIndexedDBLoading).toBe(false)
|
||||
return displayModels.value.find(model => model.id === id)
|
||||
}
|
||||
|
||||
async function addDisplayModel(format: DisplayModelFormat, file: File) {
|
||||
await until(displayModelsFromIndexedDBLoading).toBe(false)
|
||||
const newDisplayModel: DisplayModelFile = { id: `display-model-${nanoid()}`, format, type: 'file', file, name: file.name }
|
||||
displayModels.value.push(newDisplayModel)
|
||||
|
||||
localforage.setItem<DisplayModelFile>(newDisplayModel.id, newDisplayModel)
|
||||
.catch(err => console.error(err))
|
||||
}
|
||||
|
||||
async function renameDisplayModel(id: string, name: string) {
|
||||
await until(displayModelsFromIndexedDBLoading).toBe(false)
|
||||
const displayModel = await localforage.getItem<DisplayModelFile>(id)
|
||||
if (!displayModel)
|
||||
return
|
||||
|
||||
displayModel.name = name
|
||||
}
|
||||
|
||||
async function removeDisplayModel(id: string) {
|
||||
await until(displayModelsFromIndexedDBLoading).toBe(false)
|
||||
await localforage.removeItem(id)
|
||||
displayModels.value = displayModels.value.filter(model => model.id !== id)
|
||||
}
|
||||
|
||||
return {
|
||||
displayModels,
|
||||
displayModelsFromIndexedDBLoading,
|
||||
|
||||
loadDisplayModelsFromIndexedDB,
|
||||
getDisplayModel,
|
||||
addDisplayModel,
|
||||
renameDisplayModel,
|
||||
removeDisplayModel,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import localforage from 'localforage'
|
||||
|
||||
import { useBroadcastChannel, useLocalStorage } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
type BroadcastChannelEvents
|
||||
= | BroadcastChannelEventShouldUpdateView
|
||||
@@ -30,37 +28,6 @@ export const useLive2d = defineStore('live2d', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const indexedDbModelFile = ref<File | null>(null)
|
||||
|
||||
onMounted(async () => await loadModelFileFromIndexedDb())
|
||||
onShouldUpdateView(async () => await loadModelFileFromIndexedDb())
|
||||
|
||||
async function loadModelFileFromIndexedDb() {
|
||||
const file = await localforage.getItem<File>('assets-models-live2d')
|
||||
if (file) {
|
||||
indexedDbModelFile.value = file
|
||||
}
|
||||
}
|
||||
|
||||
const modelFile = computed({
|
||||
get: () => {
|
||||
return indexedDbModelFile.value
|
||||
},
|
||||
set: (file: File | null) => {
|
||||
if (file) {
|
||||
localforage.setItem('assets-models-live2d', file)
|
||||
}
|
||||
else {
|
||||
localforage.removeItem('assets-models-live2d')
|
||||
}
|
||||
|
||||
indexedDbModelFile.value = file
|
||||
},
|
||||
})
|
||||
|
||||
const defaultModelUrl = '/assets/live2d/models/hiyori_pro_zh.zip'
|
||||
const modelUrl = useLocalStorage<string>('settings/live2d/model-src', defaultModelUrl)
|
||||
|
||||
const position = useLocalStorage('settings/live2d/position', { x: 0, y: 0 }) // position is relative to the center of the screen, units are %
|
||||
const positionInPercentageString = computed(() => ({
|
||||
x: `${position.value.x}%`,
|
||||
@@ -72,8 +39,6 @@ export const useLive2d = defineStore('live2d', () => {
|
||||
const scale = useLocalStorage('settings/live2d/scale', 1)
|
||||
|
||||
return {
|
||||
modelFile,
|
||||
modelUrl,
|
||||
position,
|
||||
positionInPercentageString,
|
||||
currentMotion,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { DisplayModel } from './display-models'
|
||||
|
||||
import messages from '@proj-airi/i18n/locales'
|
||||
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { useEventListener, useLocalStorage } from '@vueuse/core'
|
||||
import { converter } from 'culori'
|
||||
import { defineStore } from 'pinia'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useAudioDevice } from './audio'
|
||||
import { DisplayModelFormat, useDisplayModelsStore } from './display-models'
|
||||
|
||||
const languageRemap: Record<string, string> = {
|
||||
'zh-CN': 'zh-Hans',
|
||||
@@ -28,12 +31,69 @@ const convert = converter('oklch')
|
||||
const getHueFrom = (color?: string) => color ? convert(color)?.h : DEFAULT_THEME_COLORS_HUE
|
||||
|
||||
export const useSettings = defineStore('settings', () => {
|
||||
const selectedAudioDevice = ref<MediaDeviceInfo>()
|
||||
const displayModelsStore = useDisplayModelsStore()
|
||||
|
||||
const language = useLocalStorage('settings/language', '')
|
||||
|
||||
const stageView = useLocalStorage('settings/stage/view/model-renderer', '2d')
|
||||
const stageModelSelected = useLocalStorage<string | undefined>('settings/stage/model', undefined)
|
||||
const stageModelSelectedDisplayModel = ref<DisplayModel | undefined>()
|
||||
const stageModelSelectedUrl = ref<string>()
|
||||
const stageModelRenderer = ref<'live2d' | 'vrm' | 'disabled'>()
|
||||
|
||||
async function updateStageModel() {
|
||||
if (!stageModelSelected.value) {
|
||||
stageModelSelectedUrl.value = undefined
|
||||
stageModelSelectedDisplayModel.value = undefined
|
||||
stageModelRenderer.value = 'disabled'
|
||||
return
|
||||
}
|
||||
|
||||
const model = await displayModelsStore.getDisplayModel(stageModelSelected.value)
|
||||
if (!model) {
|
||||
stageModelSelectedUrl.value = undefined
|
||||
stageModelSelectedDisplayModel.value = undefined
|
||||
stageModelRenderer.value = 'disabled'
|
||||
return
|
||||
}
|
||||
|
||||
if (model.type === 'file') {
|
||||
if (stageModelSelectedUrl.value) {
|
||||
URL.revokeObjectURL(stageModelSelectedUrl.value)
|
||||
}
|
||||
|
||||
stageModelSelectedUrl.value = URL.createObjectURL(model.file)
|
||||
}
|
||||
else {
|
||||
stageModelSelectedUrl.value = model.url
|
||||
}
|
||||
|
||||
switch (model.format) {
|
||||
case DisplayModelFormat.Live2dZip:
|
||||
stageModelRenderer.value = 'live2d'
|
||||
break
|
||||
case DisplayModelFormat.VRM:
|
||||
stageModelRenderer.value = 'vrm'
|
||||
break
|
||||
default:
|
||||
stageModelRenderer.value = 'disabled'
|
||||
break
|
||||
}
|
||||
|
||||
stageModelSelectedDisplayModel.value = model
|
||||
}
|
||||
|
||||
async function initializeStageModel() {
|
||||
await updateStageModel()
|
||||
}
|
||||
|
||||
useEventListener('unload', () => {
|
||||
if (stageModelSelectedUrl.value) {
|
||||
URL.revokeObjectURL(stageModelSelectedUrl.value)
|
||||
}
|
||||
})
|
||||
|
||||
const stageViewControlsEnabled = ref(false)
|
||||
|
||||
const live2dDisableFocus = useLocalStorage('settings/live2d/disable-focus', false)
|
||||
|
||||
const disableTransitions = useLocalStorage('settings/disable-transitions', true)
|
||||
@@ -91,26 +151,30 @@ export const useSettings = defineStore('settings', () => {
|
||||
return hueDifference < 0.01 || hueDifference > 359.99
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
language.value = getLanguage()
|
||||
})
|
||||
onMounted(() => language.value = getLanguage())
|
||||
|
||||
return {
|
||||
disableTransitions,
|
||||
usePageSpecificTransitions,
|
||||
language,
|
||||
stageView,
|
||||
live2dDisableFocus,
|
||||
|
||||
stageModelRenderer,
|
||||
stageModelSelected,
|
||||
stageModelSelectedUrl,
|
||||
stageModelSelectedDisplayModel,
|
||||
stageViewControlsEnabled,
|
||||
|
||||
live2dDisableFocus,
|
||||
themeColorsHue,
|
||||
themeColorsHueDynamic,
|
||||
selectedAudioDevice,
|
||||
|
||||
allowVisibleOnAllWorkspaces,
|
||||
|
||||
setThemeColorsHue,
|
||||
applyPrimaryColorFrom,
|
||||
isColorSelectedForPrimary,
|
||||
initializeStageModel,
|
||||
updateStageModel,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import localforage from 'localforage'
|
||||
|
||||
import { useBroadcastChannel, useLocalStorage } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import defaultSkyBoxSrc from '../components/Scenes/Tres/assets/sky_linekotsi_23_HDRI.hdr?url'
|
||||
|
||||
@@ -32,37 +30,6 @@ export const useVRM = defineStore('vrm', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const indexedDbModelFile = ref<File | null>(null)
|
||||
|
||||
async function loadModelFileFromIndexedDb() {
|
||||
const file = await localforage.getItem<File>('assets-models-vrm')
|
||||
if (file) {
|
||||
indexedDbModelFile.value = file
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => loadModelFileFromIndexedDb())
|
||||
// onShouldUpdateView(() => loadModelFileFromIndexedDb())
|
||||
|
||||
const modelFile = computed({
|
||||
get: () => {
|
||||
return indexedDbModelFile.value
|
||||
},
|
||||
set: (file: File | null) => {
|
||||
if (file) {
|
||||
localforage.setItem('assets-models-vrm', file)
|
||||
}
|
||||
else {
|
||||
localforage.removeItem('assets-models-vrm')
|
||||
}
|
||||
|
||||
indexedDbModelFile.value = file
|
||||
},
|
||||
})
|
||||
|
||||
const defaultModelUrl = '/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm'
|
||||
const modelUrl = useLocalStorage('settings/vrm/modelURL', defaultModelUrl)
|
||||
|
||||
const scale = useLocalStorage('settings/vrm/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 })
|
||||
@@ -117,9 +84,6 @@ export const useVRM = defineStore('vrm', () => {
|
||||
const skyBoxSrc = useLocalStorage('settings/vrm/skyBoxUrl', defaultSkyBoxSrc)
|
||||
|
||||
return {
|
||||
defaultModelUrl,
|
||||
modelFile,
|
||||
modelUrl,
|
||||
modelSize,
|
||||
|
||||
scale,
|
||||
|
||||
@@ -17,13 +17,20 @@ const isDragging = ref(false)
|
||||
const isDraggingDebounced = useDebounce(isDragging, 150)
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
files.value = []
|
||||
|
||||
if (input.files && input.files.length > 0) {
|
||||
firstFile.value = input.files[0]
|
||||
const input = e.target as HTMLInputElement
|
||||
if (!input.files)
|
||||
return
|
||||
|
||||
for (let i = 0; i < input.files?.length; i++) {
|
||||
files.value.push(input.files[i])
|
||||
}
|
||||
|
||||
if (files.value && files.value.length > 0) {
|
||||
firstFile.value = files.value[0]
|
||||
}
|
||||
|
||||
files.value = Array.from(input.files || [])
|
||||
isDragging.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -44,7 +51,7 @@ function handleFileChange(e: Event) {
|
||||
type="file"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
class="absolute inset-0 cursor-pointer appearance-none opacity-0"
|
||||
class="absolute inset-0 h-0 w-0 cursor-pointer appearance-none opacity-0"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<slot :is-dragging="isDraggingDebounced" :first-file="firstFile" :files="files" />
|
||||
|
||||
@@ -203,22 +203,40 @@ export function sharedUnoConfig() {
|
||||
*/
|
||||
animation: {
|
||||
keyframes: {
|
||||
overlayShow: '{from{opacity:0;}to{opacity:1;}}',
|
||||
overlayHide: '{from{opacity:1;}to{opacity:0;}}',
|
||||
contentShow: '{from:{opacity:0;transform:translate(-50%,-48%) scale(0.96);}to:{opacity:1;transform:translate(-50%,-50%) scale(1);}}',
|
||||
contentHide: '{from:{opacity:1;transform:translate(-50%,-50%) scale(1);}to:{opacity:0;transform:translate(-50%,-48%) scale(0.96);}}',
|
||||
slideUpAndFade: '{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:translateY(0)}}',
|
||||
slideRightAndFade: '{from{opacity:0;transform:translateX(-2px)}to{opacity:1;transform:translateX(0)}}',
|
||||
slideDownAndFade: '{from{opacity:0;transform:translateY(-2px)}to{opacity:1;transform:translateY(0)}}',
|
||||
slideLeftAndFade: '{from{opacity:0;transform:translateX(2px)}to{opacity:1;transform:translateX(0)}}',
|
||||
fadeIn: '{from{opacity:0;}to{opacity:1;}}',
|
||||
fadeOut: '{from{opacity:1;}to{opacity:0;}}',
|
||||
},
|
||||
durations: {
|
||||
overlayShow: '300ms',
|
||||
overlayHide: '300ms',
|
||||
contentShow: '150ms',
|
||||
contentHide: '150ms',
|
||||
slideUpAndFade: '400ms',
|
||||
slideRightAndFade: '400ms',
|
||||
slideDownAndFade: '400ms',
|
||||
slideLeftAndFade: '400ms',
|
||||
fadeIn: '200ms',
|
||||
fadeOut: '200ms',
|
||||
},
|
||||
timingFns: {
|
||||
overlayShow: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
overlayHide: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
contentShow: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
contentHide: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
slideUpAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
slideRightAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
slideDownAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
slideLeftAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
fadeIn: 'ease-in-out',
|
||||
fadeOut: 'ease-in-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user