feat(stage-ui): Chat Bubble with minimalism theme (#267)
--------- Co-authored-by: LemonNeko <self@lemonneko.moe>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user