feat(stage-ui): Chat Bubble with minimalism theme (#267)

---------

Co-authored-by: LemonNeko <self@lemonneko.moe>
This commit is contained in:
Neko
2025-07-14 02:45:25 +08:00
committed by GitHub
co-authored by LemonNeko
parent c283d19b7f
commit c449df548f
6 changed files with 344 additions and 43 deletions
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { sleep } from '@moeru/std'
import { onMounted, ref } from 'vue'
import ChatBubbleMinimalism from './ChatBubbleMinimalism.vue'
function createStream(text: string) {
let doneFunc = () => {}
return {
untilDone: () => {
return new Promise<void>((resolve) => {
doneFunc = () => {
resolve()
}
})
},
stream: 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)
doneFunc()
controller.close()
}
}, 10 * Math.random())
},
}),
}
}
const stream = ref<ReadableStream>()
const senderTextStream = ref<ReadableStream>()
const showSenderTextStream = ref(false)
const recipientTextStream = ref<ReadableStream>()
const recipientLoading = ref(false)
const showRecipientTextStream = ref(false)
onMounted(async () => {
// https://sooxin.github.io/Chinese-Lorem-Ipsum/
stream.value = createStream(`阜?态辞犹愿捷欣悲诚、三忙振佩珊澄鲁概爷骗炭耘肢干题蜘育饼契疫浦硫。盖零。
尝陷悬埃笔宋酵钨庭专橄?`).stream
{
// https://generator.lorem-ipsum.info/_japanese2
const textStreamRes = createStream(`もリソソンまら他差根課阿模舳素擢等れるのねはて素擢んり留等はさたによしみろ等離離樹派津夜きたせやれありゃ遊絵めゆたそそ屋御ユユソカフあゅえ樹れろは、こにとしむあまさももけ氏ひれしゅ雲差屋へ。`)
senderTextStream.value = textStreamRes.stream
showSenderTextStream.value = true
await textStreamRes.untilDone()
}
showRecipientTextStream.value = true
recipientLoading.value = true
await sleep(2000)
recipientLoading.value = false
{
// https://generator.lorem-ipsum.info/_japanese2
const textStreamRes = createStream(`無個阿巣やにせ手派しらいほね以、留絵離列知魔ヤツソセョノおやてすょほ離目津知舳露津んっそくり都阿いョシトヒツヤえょいね瀬保無区ッヤャか保津くるヌッ絵鵜んっらほ。露露、んセトフヘトターろ派津めん区等名ゅな魔絵手名もよろれ鵜以ゃゅ、ヤスカ離手つみ目根雲うちゆらめ区夜日保模舳露ラナョュあせ。
のこふえいれ絵差譜阿擢ゅょろひへちりえ、いろへね個日模手野いん留個夜御リヘヨヤトヘゅちハヤケュニオセ以個野つへぬけけゃとやなゃゆりるか目巣絵。あいっ区以差離きんそぬに絵根ょつみ離離派派津。むゆく。
サュウ御譜譜ャレニシくるむくと阿毛のも遊氏魔やこもほ無野、みえ阿ヒュソきみてゅさりよ毛手無鵜留ょ、ょむけ樹テチ模以個等み留目夜にそ露ねへはすまねかやう夜尾手魔おあ保素遊せ留雲根いぬこ素屋譜雲氏みむゅまろいんぬ屋模無二まほめやいへれふたおいね、そたにけそね名樹ち個お、れ派個絵にち以無模留ヌロナニャ、にのほまふ列やき二模擢うしょすて知根と手阿やゅけ。`)
recipientTextStream.value = textStreamRes.stream
await textStreamRes.untilDone()
}
})
</script>
<template>
<Story
title="Chat Bubble (Minimalism)"
group="gadgets"
:layout="{ type: 'grid', width: '100%' }"
>
<template #controls>
<ThemeColorsHueControl />
</template>
<Variant
id="default"
title="Default"
>
<ChatBubbleMinimalism :text="stream" w-100 font-xiaolai />
</Variant>
<Variant
id="loading"
title="Loading"
>
<ChatBubbleMinimalism w-100 font-xiaolai side="left" :loading="true" />
</Variant>
<Variant
id="chat"
title="Chat"
>
<div bg="neutral-50 dark:neutral-900" w-120 flex flex-col gap-2 overflow-y-scroll rounded-xl p-4 font-m-plus-rounded>
<ChatBubbleMinimalism v-if="showSenderTextStream" :text="senderTextStream" side="left" w-100 self-start />
<ChatBubbleMinimalism v-if="showRecipientTextStream" :text="recipientTextStream" w-100 self-end :loading="recipientLoading" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { until } from '@vueuse/core'
import { createTimeline } from 'animejs'
import { nextTick, onMounted, ref } from 'vue'
import PoppinText from '../Widgets/PoppinText/PoppinText.web.vue'
import { createFadeAnimator } from '../Widgets/PoppinText/animators'
const props = withDefaults(
defineProps<{
containerClass?: string | string[]
containerClassExtra?: string | string[]
text?: string | ReadableStream
textClass?: string | string[]
side?: 'left' | 'right'
loading?: boolean
}>(),
{
side: 'right',
},
)
const chatBubbleContainerRef = ref<HTMLDivElement>()
const chatBubbleRef = ref<HTMLDivElement>()
onMounted(async () => {
await until(chatBubbleContainerRef).toBeTruthy()
await until(chatBubbleRef).toBeTruthy()
const containerWidth = chatBubbleContainerRef.value!.getBoundingClientRect().width
createTimeline({ loop: false })
.set(chatBubbleRef.value!, {
width: 0,
borderWidth: 0,
paddingLeft: 0,
paddingRight: 0,
paddingTop: 0,
paddingBottom: 0,
ease: 'outQuart',
duration: 500,
})
.add(chatBubbleRef.value!, {
width: [0, props.loading ? 'fit-content' : containerWidth],
borderWidth: [0, 2],
paddingLeft: [0, 12],
paddingRight: [0, 12],
paddingTop: [0, 12],
paddingBottom: [0, 12],
ease: 'outQuart',
duration: 500,
})
})
async function handleOnTextSplit(_: string) {
// Scroll to bottom
await nextTick()
await scrollToBottom()
}
async function scrollToBottom() {
if (chatBubbleRef.value) {
const borderHeight = 4 + 1 // 4px from border-2 class (top and bottom), 1px mystery addition
chatBubbleRef.value.style.maxHeight = `${chatBubbleRef.value.scrollHeight + borderHeight}px`
await nextTick()
}
}
</script>
<template>
<div ref="chatBubbleContainerRef">
<div
ref="chatBubbleRef"
transition="max-height,max-width,colors duration-500 ease-in-out"
relative overflow-y-auto will-change-max-height will-change-max-width scrollbar-none
:class="[
props.side === 'left' ? 'rounded-tr-2xl rounded-br-2xl rounded-tl-2xl rounded-bl-sm' : 'rounded-tl-2xl rounded-bl-2xl rounded-tr-2xl rounded-br-sm',
props.side === 'left' ? '' : 'float-right',
...(props.containerClass
? (typeof props.containerClass === 'string' ? [props.containerClass] : props.containerClass)
: [
'border-2 border-solid border-neutral-100 dark:border-neutral-900',
'bg-white/90 backdrop-blur-md dark:bg-neutral-950/90',
...(props.containerClassExtra
? (typeof props.containerClassExtra === 'string' ? [props.containerClassExtra] : props.containerClassExtra)
: []),
]),
]"
>
<div v-if="props.loading" i-svg-spinners:3-dots-scale />
<PoppinText
v-else
min-h-0
:text="props.text"
:text-class="[...(typeof props.textClass === 'string' ? [props.textClass] : (props.textClass || [])), 'vertical-middle']"
:animator="createFadeAnimator({ duration: 100 })"
@text-split="handleOnTextSplit"
/>
</div>
</div>
</template>
@@ -4,7 +4,13 @@ import { ref } from 'vue'
import PoppinText from './PoppinText.web.vue'
import { createFadeAnimator, createFloatAnimator, createPopupAnimator, createStackAnimator } from './animators'
import {
createCutePopupAnimator,
createFadeAnimator,
createFloatAnimator,
createPopupAnimator,
createStackAnimator,
} from './animators'
const text = ref('行こう、七色のキラキラドキドキに向かって!')
const duration = ref(750)
@@ -40,48 +46,26 @@ const mixedLanguageStream = createStream('g̈각நிกำषिक्षि')
:layout="{ type: 'grid', width: '100%' }"
>
<template #controls>
<FieldRange
v-model.number="duration"
:min="50"
:max="3000"
:step="50"
label="Duration"
/>
</template>
<Variant
id="popup"
title="Popup"
>
<div h-auto w-full p-4>
<PoppinText :text="text" :animator="createPopupAnimator({ duration })" />
<div px-2>
<FieldRange
v-model.number="duration"
:min="50"
:max="3000"
:step="50"
label="Duration"
/>
</div>
</Variant>
</template>
<Variant
id="fade"
title="Fade"
>
<div h-auto w-full p-4>
<PoppinText :text="text" :animator="createFadeAnimator({ duration })" />
</div>
</Variant>
<Variant
id="float"
title="Float"
>
<div h-auto w-full p-4>
<PoppinText :text="text" :animator="createFloatAnimator({ duration })" />
</div>
</Variant>
<Variant
id="stack"
title="Stack"
>
<div h-auto w-full p-4>
<PoppinText :text="text" :animator="createStackAnimator({ duration })" />
<PoppinText
:text="text" :animator="createFadeAnimator({ duration })"
text-2xl font-bold font-m-plus-rounded
/>
</div>
</Variant>
@@ -90,7 +74,11 @@ const mixedLanguageStream = createStream('g̈각நிกำषिक्षि')
title="Stream"
>
<div h-auto w-full p-4>
<PoppinText :text="textStream" :animator="createFloatAnimator({ duration })" />
<PoppinText
:text="textStream"
:animator="createFloatAnimator({ duration })"
text-2xl font-bold font-m-plus-rounded
/>
</div>
</Variant>
@@ -99,7 +87,11 @@ const mixedLanguageStream = createStream('g̈각நிกำषिक्षि')
title="Emoji stream"
>
<div h-auto w-full p-4>
<PoppinText :text="emojiStream" :animator="createFloatAnimator({ duration })" />
<PoppinText
:text="emojiStream"
:animator="createFloatAnimator({ duration })"
text-2xl font-bold font-m-plus-rounded
/>
</div>
</Variant>
@@ -108,7 +100,58 @@ const mixedLanguageStream = createStream('g̈각நிกำषिक्षि')
title="Mixed language stream"
>
<div h-auto w-full p-4>
<PoppinText :text="mixedLanguageStream" :animator="createFloatAnimator({ duration })" />
<PoppinText
:text="mixedLanguageStream"
:animator="createFloatAnimator({ duration })"
/>
</div>
</Variant>
<Variant
id="popup"
title="Popup"
>
<div h-auto w-full p-4>
<PoppinText
:text="text"
:animator="createPopupAnimator({ duration })"
/>
</div>
</Variant>
<Variant
id="cute-popup"
title="Cute Popup"
>
<div h-auto w-full p-4>
<PoppinText
:text="text"
:animator="createCutePopupAnimator({ duration })"
/>
</div>
</Variant>
<Variant
id="float"
title="Float"
>
<div h-auto w-full p-4>
<PoppinText
:text="text"
:animator="createFloatAnimator({ duration })"
/>
</div>
</Variant>
<Variant
id="stack"
title="Stack"
>
<div h-auto w-full p-4>
<PoppinText
:text="text"
:animator="createStackAnimator({ duration })"
/>
</div>
</Variant>
</Story>
@@ -6,10 +6,15 @@ import type { Animator } from './animators'
import { onMounted, ref, shallowRef, watch } from 'vue'
const props = defineProps<{
text: string | ReadableStream<Uint8Array>
text?: string | ReadableStream<Uint8Array>
textClass?: string | string[]
animator?: Animator
}>()
const emits = defineEmits<{
(e: 'textSplit', grapheme: string): void
}>()
const targets = ref<string[]>([])
const abortController = shallowRef<AbortController>()
const segmenter = new Intl.Segmenter('und', { granularity: 'grapheme' })
@@ -74,6 +79,8 @@ function readGraphemeClusters(stream: ReadableStream<Uint8Array>, options?: { si
}
watch(() => props.text, async (text) => {
if (!text)
return
if (typeof text === 'string') {
targets.value = [...segmenter.segment(text)].map(seg => seg.segment)
}
@@ -84,6 +91,7 @@ watch(() => props.text, async (text) => {
targets.value = []
for await (const cluster of readGraphemeClusters(text, { signal: abortController.value.signal })) {
targets.value.push(cluster)
emits('textSplit', cluster)
}
}
catch (error) {
@@ -119,13 +127,13 @@ watch([targets, () => props.animator], ([targets, animator]) => {
</script>
<template>
<div class="relative overflow-hidden whitespace-nowrap text-2xl font-bold font-m-plus-rounded">
<div>
<span
v-for="(grapheme, index) in targets"
:key="index"
ref="elements"
class="inline-block color-primary-400"
:style="{ '--chromatic-hue': index * 360 / targets.length }"
class="inline-block color-primary-400 dark:color-primary-100"
:class="[...(typeof props.textClass === 'string' ? [props.textClass] : (props.textClass || []))]"
>
{{ grapheme }}
</span>
@@ -11,4 +11,5 @@ export type Animator = (elements: HTMLElement[]) => (() => void)
export * from './fade'
export * from './float'
export * from './popup'
export * from './scale-popup'
export * from './stack'
@@ -0,0 +1,33 @@
import type { Animator, CreateAnimatorOptions } from '.'
import { createSpring, createTimeline } from 'animejs'
export function createCutePopupAnimator(options: CreateAnimatorOptions): Animator {
return (elements: HTMLElement[]) => {
if (elements.length === 0) {
// NO DIV0
return () => {}
}
const timeline = createTimeline({ loop: options.loop })
.set(elements, {
opacity: 0,
scale: 0,
translateY: '1.1em',
translateZ: 0,
})
.add(elements, {
opacity: [0, 1],
scale: [0, 1],
translateY: ['1.1em', 0],
translateZ: 0,
...options,
delay: (_, i) => options.duration / elements.length * i,
ease: createSpring(),
})
return () => {
timeline.remove(elements)
}
}
}