docs(devlog): DevLog @ 2025.08.01 (#339)
This commit is contained in:
@@ -211,6 +211,7 @@ words:
|
||||
- Sniglet
|
||||
- sonner
|
||||
- specta
|
||||
- splt
|
||||
- srgb
|
||||
- ssml
|
||||
- staticlib
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { animate } from 'animejs'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
|
||||
import CharacterShowcase from './CharacterShowcase.vue'
|
||||
|
||||
interface Character {
|
||||
value: string
|
||||
variant?: InstanceType<typeof CharacterShowcase>['variant']
|
||||
}
|
||||
|
||||
function interpolate(characters: string[], initial?: Character[]) {
|
||||
return characters.reduce<Character[][]>((chars, c) => {
|
||||
return [
|
||||
...chars,
|
||||
[
|
||||
...chars.length > 0 ? chars[chars.length - 1] : [],
|
||||
{ value: c, variant: 'dotted' },
|
||||
],
|
||||
]
|
||||
}, initial ? [initial] : [])
|
||||
}
|
||||
|
||||
const STATES: Character[][] = [
|
||||
...interpolate([...'💆🏼♀️'].splice(0, 2)),
|
||||
...interpolate([...'💆🏼♀️'].splice(2), [{ value: '💆🏼', variant: 'default' }]),
|
||||
...interpolate([...'👩🏻💻'].splice(0, 2), [{ value: '💆🏼♀️', variant: 'default' }]),
|
||||
...interpolate([...'👩🏻💻'].splice(2), [{ value: '💆🏼♀️', variant: 'active' }, { value: '👩🏻', variant: 'default' }]),
|
||||
[{ value: '💆🏼♀️', variant: 'active' }, { value: '👩🏻💻', variant: 'active' }],
|
||||
]
|
||||
|
||||
const stateIndex = ref(0)
|
||||
const isPlaying = ref(true)
|
||||
const animationHandle = ref<number>()
|
||||
|
||||
function enterAnimator(e: Element, done: () => void) {
|
||||
return animate(e, {
|
||||
opacity: [0, 1],
|
||||
scale: [0.5, 1],
|
||||
ease: 'outQuad',
|
||||
duration: 200,
|
||||
onComplete: done,
|
||||
})
|
||||
}
|
||||
|
||||
function leaveAnimator(e: Element, done: () => void) {
|
||||
return animate(e, {
|
||||
opacity: [1, 0],
|
||||
scale: [1, 0.5],
|
||||
ease: 'outQuad',
|
||||
duration: 200,
|
||||
onComplete: done,
|
||||
})
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!import.meta.env.SSR) {
|
||||
if (isPlaying.value) {
|
||||
animationHandle.value = window.setInterval(() => {
|
||||
stateIndex.value = (stateIndex.value + 1) % STATES.length
|
||||
}, 1000)
|
||||
}
|
||||
else {
|
||||
if (animationHandle.value) {
|
||||
window.clearInterval(animationHandle.value)
|
||||
animationHandle.value = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function stepForward() {
|
||||
isPlaying.value = false
|
||||
stateIndex.value = (stateIndex.value + 1) % STATES.length
|
||||
}
|
||||
|
||||
function stepBack() {
|
||||
isPlaying.value = false
|
||||
stateIndex.value = (stateIndex.value - 1 + STATES.length) % STATES.length
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col items-center justify-start gap-1" bg="primary/5" min-h-80 w-full rounded-lg p-2>
|
||||
<div flex="~ row items-stretch gap-2 grow" bg="primary/5" w-full rounded-lg p-2>
|
||||
<div flex="~ col items-center justify-start gap-1" py-2>
|
||||
<div
|
||||
flex="~ row items-center"
|
||||
rounded-lg p="2"
|
||||
bg="hover:primary/10"
|
||||
transition="~ all duration-150 ease-out"
|
||||
cursor="pointer"
|
||||
@click="isPlaying = !isPlaying"
|
||||
>
|
||||
<div v-if="!isPlaying" i-lucide:play cursor="pointer" />
|
||||
<div v-else i-lucide:pause cursor="pointer" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
flex="~ row items-center"
|
||||
rounded-lg p="2"
|
||||
bg="hover:primary/10"
|
||||
transition="~ all duration-150 ease-out"
|
||||
cursor="pointer"
|
||||
@click="stepForward"
|
||||
>
|
||||
<div i-lucide:step-forward />
|
||||
</div>
|
||||
|
||||
<div
|
||||
flex="~ row items-center"
|
||||
rounded-lg p="2"
|
||||
bg="hover:primary/10"
|
||||
transition="~ all duration-150 ease-out"
|
||||
cursor="pointer"
|
||||
@click="stepBack"
|
||||
>
|
||||
<div i-lucide:step-back />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
flex="~ row items-start gap-1 grow"
|
||||
transition="~ all duration-150 ease-out"
|
||||
overflow="x-scroll"
|
||||
bg="primary/5" w-full rounded-lg p-2
|
||||
>
|
||||
<TransitionGroup
|
||||
:css="false"
|
||||
@enter="enterAnimator"
|
||||
@leave="leaveAnimator"
|
||||
>
|
||||
<CharacterShowcase
|
||||
v-for="(c, i) in STATES[stateIndex]"
|
||||
:key="i"
|
||||
:value="c.value"
|
||||
:variant="c.variant"
|
||||
code-point
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div flex="~ row items-center justify-center gap-1 wrap" py-2 text-xs>
|
||||
<div font-semibold w="full md:auto" text="center md:unset">
|
||||
Legend
|
||||
</div>
|
||||
<div
|
||||
b="~ 2 dotted primary/20"
|
||||
rounded-lg px-2
|
||||
flex="~ items-center justify-center shrink-0"
|
||||
transition="~ all duration-150 ease-out"
|
||||
>
|
||||
Character
|
||||
</div>
|
||||
<div
|
||||
b="~ 2 dashed primary/20"
|
||||
rounded-lg px-2
|
||||
flex="~ items-center justify-center shrink-0"
|
||||
transition="~ all duration-150 ease-out"
|
||||
>
|
||||
Incomplete cluster
|
||||
</div>
|
||||
<div
|
||||
b="~ 2 solid primary/50"
|
||||
bg="primary/10"
|
||||
rounded-lg px-2
|
||||
flex="~ items-center justify-center shrink-0"
|
||||
transition="~ all duration-150 ease-out"
|
||||
>
|
||||
Complete cluster
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const { variant = 'default' } = defineProps<{
|
||||
value: string
|
||||
variant?: 'default' | 'dotted' | 'active' | 'connector'
|
||||
codePoint?: boolean
|
||||
invisibleCodePoint?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col items-center gap-1 justify-start items-center">
|
||||
<div
|
||||
b="~ 2"
|
||||
:class="{
|
||||
'b-solid b-primary/50 bg-primary/10 w-10': variant === 'active',
|
||||
'b-dotted b-primary/20 w-10': variant === 'dotted',
|
||||
'b-dashed b-primary/20 w-10': variant === 'default',
|
||||
'b-transparent bg-transparent': variant === 'connector',
|
||||
}"
|
||||
h-10 rounded-lg text-lg
|
||||
flex="~ items-center justify-center"
|
||||
transition="~ all duration-150 ease-out"
|
||||
>
|
||||
{{ value }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="codePoint || invisibleCodePoint"
|
||||
text-xs text="primary" font-mono
|
||||
flex="~ col items-center justify-center"
|
||||
:class="{ invisible: invisibleCodePoint }"
|
||||
>
|
||||
<div v-for="char in value" :key="char">
|
||||
{{ char.codePointAt(0)?.toString(16).toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import CharacterShowcase from './CharacterShowcase.vue'
|
||||
|
||||
defineProps<{
|
||||
characters: string[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ row justify-center items-start gap-1">
|
||||
<template v-for="(char, i) in characters" :key="i">
|
||||
<CharacterShowcase
|
||||
:value="char"
|
||||
code-point
|
||||
/>
|
||||
<CharacterShowcase
|
||||
v-if="i < characters.length - 1"
|
||||
value="+"
|
||||
invisible-code-point
|
||||
variant="connector"
|
||||
/>
|
||||
</template>
|
||||
<CharacterShowcase
|
||||
value="="
|
||||
invisible-code-point
|
||||
variant="connector"
|
||||
/>
|
||||
<CharacterShowcase
|
||||
:value="characters.join('')"
|
||||
code-point
|
||||
variant="active"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { animate } from 'animejs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import CharacterShowcase from './CharacterShowcase.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
initText: string
|
||||
}>()
|
||||
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
|
||||
const text = ref(props.initText)
|
||||
const highlightedClusterIndex = ref(-1)
|
||||
|
||||
const segments = computed(() => [...segmenter.segment(text.value)])
|
||||
|
||||
function enterAnimator(e: Element, done: () => void) {
|
||||
return animate(e, {
|
||||
opacity: [0, 1],
|
||||
scale: [0.5, 1],
|
||||
ease: 'outQuad',
|
||||
duration: 200,
|
||||
onComplete: done,
|
||||
})
|
||||
}
|
||||
|
||||
function leaveAnimator(e: Element, done: () => void) {
|
||||
return animate(e, {
|
||||
opacity: [1, 0],
|
||||
scale: [1, 0.5],
|
||||
ease: 'outQuad',
|
||||
duration: 200,
|
||||
onComplete: done,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full" grid="~ cols-[auto] md:cols-[min-content_auto]" overflow-hidden rounded-lg>
|
||||
<div
|
||||
bg="primary/5"
|
||||
flex="~ items-center justify-start md:justify-end"
|
||||
p="2 md:e-0" text-sm font-semibold
|
||||
>
|
||||
Text
|
||||
</div>
|
||||
<div bg="primary/5" p-2>
|
||||
<input v-model="text" name="text" bg="primary/10" w-full rounded-lg p-2 text="md:lg">
|
||||
</div>
|
||||
|
||||
<div
|
||||
whitespace-nowrap bg="primary/10"
|
||||
flex="~ items-center justify-start md:justify-end"
|
||||
p="2 md:e-0" text-sm font-semibold
|
||||
>
|
||||
Grapheme clusters
|
||||
</div>
|
||||
<div bg="primary/10" flex="~ row gap-2 wrap" p-2>
|
||||
<TransitionGroup
|
||||
:css="false"
|
||||
@enter="enterAnimator"
|
||||
@leave="leaveAnimator"
|
||||
>
|
||||
<CharacterShowcase
|
||||
v-for="(segment, segIndex) in segments"
|
||||
:key="segIndex"
|
||||
:variant="highlightedClusterIndex === segIndex ? 'active' : 'default'"
|
||||
cursor-pointer
|
||||
:value="segment.segment"
|
||||
@mouseover="highlightedClusterIndex = segIndex"
|
||||
@mouseleave="highlightedClusterIndex = -1"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<div
|
||||
whitespace-nowrap bg="primary/15"
|
||||
flex="~ items-center justify-start md:justify-end"
|
||||
p="2 md:e-0" text-sm font-semibold
|
||||
>
|
||||
Characters
|
||||
</div>
|
||||
<div bg="primary/15" flex="~ row gap-2 wrap" p-2>
|
||||
<TransitionGroup
|
||||
:css="false"
|
||||
@enter="enterAnimator"
|
||||
@leave="leaveAnimator"
|
||||
>
|
||||
<template v-for="(segment, segIndex) in segments" :key="segIndex">
|
||||
<CharacterShowcase
|
||||
v-for="(cp, cpIndex) in [...segment.segment]"
|
||||
:key="cpIndex"
|
||||
:variant="highlightedClusterIndex === segIndex ? 'active' : 'default'"
|
||||
:value="cp"
|
||||
code-point
|
||||
cursor-pointer
|
||||
@mouseover="highlightedClusterIndex = segIndex" @mouseleave="highlightedClusterIndex = -1"
|
||||
/>
|
||||
</template>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { TextSplitter, Timeline } from 'animejs'
|
||||
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { onMounted, shallowRef, useTemplateRef, watchEffect } from 'vue'
|
||||
|
||||
const animatedText = useTemplateRef('animatedText')
|
||||
const shouldReduceMotion = useLocalStorage('docs:settings/reduce-motion', false) // A11y-friendly!
|
||||
|
||||
const animatedChars = shallowRef<TextSplitter['chars']>()
|
||||
const timeline = shallowRef<Timeline>()
|
||||
|
||||
onMounted(async () => {
|
||||
const { createTimeline, stagger, text } = await import('animejs')
|
||||
|
||||
const { chars } = text.split(animatedText.value!, {
|
||||
chars: { wrap: 'clip', clone: 'bottom' },
|
||||
accessible: true,
|
||||
})
|
||||
animatedChars.value = chars
|
||||
|
||||
timeline.value = createTimeline({
|
||||
loop: true,
|
||||
defaults: { ease: 'inOut(3)', duration: 650 },
|
||||
})
|
||||
.add(chars, {
|
||||
y: '-100%',
|
||||
opacity: [1, 0, 1],
|
||||
loop: true,
|
||||
loopDelay: 350,
|
||||
duration: 1000,
|
||||
ease: 'inOut(2)',
|
||||
}, stagger(150, { from: 'random' }))
|
||||
.reset()
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (shouldReduceMotion.value) {
|
||||
timeline.value?.reset()
|
||||
}
|
||||
else {
|
||||
timeline.value?.play()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot name="before" :motion-reduced="shouldReduceMotion" />
|
||||
|
||||
<div class="relative" v-bind="$attrs">
|
||||
<div ref="animatedText">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="absolute left-0 top-0 op-20" aria-hidden>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="after" :motion-reduced="shouldReduceMotion" />
|
||||
</template>
|
||||
Binary file not shown.
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: DevLog @ 2025.08.01
|
||||
category: DevLog
|
||||
date: 2025-08-01
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import CharacterMatcher from './CharacterMatcher.vue'
|
||||
import GraphemeClusterAssembler from './GraphemeClusterAssembler.vue'
|
||||
import GraphemeClusterInspector from './GraphemeClusterInspector.vue'
|
||||
import RollingText from './RollingText.vue'
|
||||
</script>
|
||||
|
||||
## Before we start
|
||||
|
||||
<RollingText text-2xl>
|
||||
Hello, this is Makito.
|
||||
|
||||
<template #before="{ motionReduced }">
|
||||
<div text-sm>
|
||||
<template v-if="!motionReduced">
|
||||
|
||||
> The animation below can be turned off with the "Reduce Motion" toggle in the top-right corner.
|
||||
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
> **The animation below has been turned off** <br />
|
||||
> You can turn it on with the "Reduce Motion" toggle in the top-right corner.
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</RollingText>
|
||||
|
||||
Endless August has begun… Maybe we can pass the time with this [realistic math problem](https://oeis.org/A180632/a180632.pdf). Oops, sorry for getting off-topic.
|
||||
|
||||
This is my first post on Project AIRI's DevLog, even though I have been working on it for a while.
|
||||
|
||||
In this post, I will share my journey from implementing text animations in AIRI to building a library to handle grapheme clusters as they arrive in a stream of UTF-8 bytes. I hope you find it informative and inspiring!
|
||||
|
||||
## Background
|
||||
|
||||
Recently, [Anime.js](https://animejs.com/) released its new [text utilities](https://animejs.com/documentation/text) in v4.10, providing a collection of utility functions to help with text animations (as shown above). This update indeed fills a gap Anime.js has had for a while. Previously, I had to manually split text into individual characters for animation, or rely on some libraries like [splt](https://www.spltjs.com/)—which uses Anime.js under the hood—or [SplitText](https://gsap.com/docs/v3/Plugins/SplitText/) in combination with [GSAP](https://gsap.com/).
|
||||
|
||||
Text animations are especially useful for making messages appear in a fancy way in the UI. Typically, messages are received fully formed, so we only need to split the received text into characters and animate them.
|
||||
|
||||
In Project AIRI, [@nekomeowww](https://github.com/nekomeowww) also built an animated chat bubble component with motion effects:
|
||||
|
||||
<video controls muted autoplay loop max-w="500px" w-full mx-auto>
|
||||
<source src="./assets/animated-chat-bubble.mp4">
|
||||
</video>
|
||||
|
||||
<div text-sm text-center>
|
||||
|
||||
Check it out in [our UI storybook](https://airi.moeru.ai/ui/#/story/src-components-gadgets-chatbubbleminimalism-story-vue?variantId=chat)
|
||||
|
||||
</div>
|
||||
|
||||
However, what if we want to read a stream of UTF-8 bytes and animate them as they arrive? This is common in real-time applications, such as chat or audio transcription apps—the UI displays text as it is received, character by character.
|
||||
|
||||
## Character by character?
|
||||
|
||||
What should be considered a "character" in this context? In Unicode, the smallest meaningful unit of text is typically a [code point](https://www.unicode.org/versions/Unicode14.0.0/ch02.pdf#G25564). However, at the encoding level—especially in UTF-8—a single code point can span multiple bytes. For example, the character "あ" (the Japanese Hiragana letter A) corresponds to the code point `U+3042`, which is encoded as the byte sequence `0xE3 0x81 0x82` in UTF-8. This means that when reading a byte stream, we may not have a complete character until all its bytes are available.
|
||||
|
||||
Don't worry, the Web API [TextDecoder](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder) is there to help. By using `TextDecoder.decode` with the `stream` option, the decoder will handle data that arrives in chunks, allowing us to decode partial characters correctly.
|
||||
|
||||
```javascript
|
||||
const decoder = new TextDecoder()
|
||||
const decoded = decoder.decode(chunk, { stream: true })
|
||||
```
|
||||
|
||||
## Are we safe?
|
||||
|
||||
tl;dr: **Not exactly**.
|
||||
|
||||
TextDecoder can help us decode a stream of bytes into Unicode code points, or characters, correctly. Nevertheless, in Unicode, there's another "grapheme cluster" concept, which combines multiple code points into a single "visual" character. For example, the emoji "👩👩👧👦" (family) is represented by multiple code points but is visually treated as a single character. Under the hood, the code points in "👩👩👧👦" are joined together using zero-width joiners (ZWJs), whose code is `U+200D`.
|
||||
|
||||
This could be hard to imagine. Don't worry. I built a simple interactive inspector for you to explore grapheme clusters and code points and understand how they are combined. Pay attention to the `200D` code points in the breakdown:
|
||||
|
||||
<GraphemeClusterInspector initText="👩👩👧👦🏄♀️🤼♂️🙋♀️" />
|
||||
|
||||
<div text-sm text-center>
|
||||
|
||||
Try hovering over the grapheme clusters or code points to see how they are combined. You can also change the text to inspect any text you want.
|
||||
|
||||
</div>
|
||||
|
||||
Similar to emojis, some languages also use combining code points to create complex characters. For example, the Tamil letter "நி" (ni) is represented by the base character "ந" (na) and the combining vowel "ி" (i). When these are combined, they form a single grapheme cluster that visually represents the character "நி". Check out the inspector below to see how they break down:
|
||||
|
||||
<GraphemeClusterInspector initText="நிกำषिक्षि" /> <!-- cSpell:disable-line -->
|
||||
|
||||
## Build a reader
|
||||
|
||||
It's relatively easy to split a fixed-length string into grapheme clusters, but in the scenario of streaming, we are looking into a pipe where bytes flow out continuously. In the worst case, we only see a single byte at a time. Furthermore, because of the nature of UTF-8, we cannot safely assume that the bytes we receive are complete for a code point, as a code point can be made up of at most 4 bytes.
|
||||
|
||||
To address this, we can use [TextDecoder](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder) mentioned earlier. Upon receiving and decoding, we concatenate the decoded string to a buffer, where the grapheme clusters will be composed correctly.
|
||||
|
||||
Now that we have a pipeline to assemble the string back from bytes, we should start to worry about how to <b title="Because safety first" underline="~ dotted" cursor-help>safely</b> read grapheme clusters from the string. Luckily, [`Intl.Segmenter`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter) is happy to help. It provides an official way to split a string into grapheme clusters, with awareness of locales in mind. `Intl.Segmenter` is more than a utility for grapheme clusters. It can also segment text into words or sentences, depending on the options you provide.
|
||||
|
||||
Let's imagine that we have received some bytes and they were correctly decoded into the following grapheme cluster:
|
||||
|
||||
<div flex="~ row items-center justify-center gap-1" overflow="x-scroll">
|
||||
<GraphemeClusterAssembler :characters="[...'👩👧']" />
|
||||
</div>
|
||||
|
||||
By this time, "👩👧" (2 people) itself is a grapheme cluster. Can we take it out and start reading the following bytes? Not yet. In fact, if more bytes arrive, the previous grapheme cluster will become "👩👧👦" (3 people):
|
||||
|
||||
<div flex="~ row items-center justify-center gap-1" overflow="x-scroll">
|
||||
<GraphemeClusterAssembler :characters="['👩👧', '', '👦']" />
|
||||
</div>
|
||||
|
||||
If we emit the "👩👧" (2 people) a step earlier, we will produce an incomplete grapheme cluster, which is not what we are expecting.
|
||||
|
||||
## ASAP but safely
|
||||
|
||||
In some scenarios, you may want to read these (complete, of course) grapheme clusters out as early as possible. We still use `Intl.Segmenter`, but with a slightly different dequeuing strategy. If we cannot assume whether the current grapheme cluster is complete, we can wait until the next one appears, and emit the ones except the last one:
|
||||
|
||||
```ts
|
||||
declare let clusterBuffer: string
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
while (true) {
|
||||
const segments = [...segmenter.segment(clusterBuffer)]
|
||||
segments.pop() // Discard the last segment
|
||||
for (const seg of segments) {
|
||||
yield seg.segment // Emit complete grapheme clusters
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This way, the potentially incomplete grapheme cluster will never be the current one, but the next one. I built another interactive component to demonstrate this:
|
||||
|
||||
<CharacterMatcher />
|
||||
|
||||
<div text-sm text-center>
|
||||
|
||||
You may see how we wait until the second grapheme cluster to appear before emitting the first one.
|
||||
|
||||
</div>
|
||||
|
||||
## Introducing Clustr
|
||||
|
||||
By the time I wrote this DevLog, there are many nice libraries that help you split a string into grapheme clusters for you to choose from. However, among them, I didn't find one that both accepts a stream of UTF-8 bytes and emits grapheme clusters as they arrive. So I built one myself, with the approach described above, which I named [Clustr](https://github.com/sumimakito/clustr) to give it some resonance with the "grapheme cluster" concept in Unicode.
|
||||
|
||||
Although the total line count of its core is less than 100, it may help you with your next project where you want to have some fancy text animations from a stream of UTF-8 bytes—like what we did in Project AIRI.
|
||||
|
||||
If you are interested in what we're doing in Project AIRI, please check out our GitHub repository at [moeru-ai/airi](https://github.com/moeru-ai/airi)!
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { animate } from 'animejs'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
|
||||
import CharacterShowcase from './CharacterShowcase.vue'
|
||||
|
||||
interface Character {
|
||||
value: string
|
||||
variant?: InstanceType<typeof CharacterShowcase>['variant']
|
||||
}
|
||||
|
||||
function interpolate(characters: string[], initial?: Character[]) {
|
||||
return characters.reduce<Character[][]>((chars, c) => {
|
||||
return [
|
||||
...chars,
|
||||
[
|
||||
...chars.length > 0 ? chars[chars.length - 1] : [],
|
||||
{ value: c, variant: 'dotted' },
|
||||
],
|
||||
]
|
||||
}, initial ? [initial] : [])
|
||||
}
|
||||
|
||||
const STATES: Character[][] = [
|
||||
...interpolate([...'💆🏼♀️'].splice(0, 2)),
|
||||
...interpolate([...'💆🏼♀️'].splice(2), [{ value: '💆🏼', variant: 'default' }]),
|
||||
...interpolate([...'👩🏻💻'].splice(0, 2), [{ value: '💆🏼♀️', variant: 'default' }]),
|
||||
...interpolate([...'👩🏻💻'].splice(2), [{ value: '💆🏼♀️', variant: 'active' }, { value: '👩🏻', variant: 'default' }]),
|
||||
[{ value: '💆🏼♀️', variant: 'active' }, { value: '👩🏻💻', variant: 'active' }],
|
||||
]
|
||||
|
||||
const stateIndex = ref(0)
|
||||
const isPlaying = ref(true)
|
||||
const animationHandle = ref<number>()
|
||||
|
||||
function enterAnimator(e: Element, done: () => void) {
|
||||
return animate(e, {
|
||||
opacity: [0, 1],
|
||||
scale: [0.5, 1],
|
||||
ease: 'outQuad',
|
||||
duration: 200,
|
||||
onComplete: done,
|
||||
})
|
||||
}
|
||||
|
||||
function leaveAnimator(e: Element, done: () => void) {
|
||||
return animate(e, {
|
||||
opacity: [1, 0],
|
||||
scale: [1, 0.5],
|
||||
ease: 'outQuad',
|
||||
duration: 200,
|
||||
onComplete: done,
|
||||
})
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!import.meta.env.SSR) {
|
||||
if (isPlaying.value) {
|
||||
animationHandle.value = window.setInterval(() => {
|
||||
stateIndex.value = (stateIndex.value + 1) % STATES.length
|
||||
}, 1000)
|
||||
}
|
||||
else {
|
||||
if (animationHandle.value) {
|
||||
window.clearInterval(animationHandle.value)
|
||||
animationHandle.value = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function stepForward() {
|
||||
isPlaying.value = false
|
||||
stateIndex.value = (stateIndex.value + 1) % STATES.length
|
||||
}
|
||||
|
||||
function stepBack() {
|
||||
isPlaying.value = false
|
||||
stateIndex.value = (stateIndex.value - 1 + STATES.length) % STATES.length
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col items-center justify-start gap-1" bg="primary/5" min-h-80 w-full rounded-lg p-2>
|
||||
<div flex="~ row items-stretch gap-2 grow" bg="primary/5" w-full rounded-lg p-2>
|
||||
<div flex="~ col items-center justify-start gap-1" py-2>
|
||||
<div
|
||||
flex="~ row items-center"
|
||||
rounded-lg p="2"
|
||||
bg="hover:primary/10"
|
||||
transition="~ all duration-150 ease-out"
|
||||
cursor="pointer"
|
||||
@click="isPlaying = !isPlaying"
|
||||
>
|
||||
<div v-if="!isPlaying" i-lucide:play cursor="pointer" />
|
||||
<div v-else i-lucide:pause cursor="pointer" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
flex="~ row items-center"
|
||||
rounded-lg p="2"
|
||||
bg="hover:primary/10"
|
||||
transition="~ all duration-150 ease-out"
|
||||
cursor="pointer"
|
||||
@click="stepForward"
|
||||
>
|
||||
<div i-lucide:step-forward />
|
||||
</div>
|
||||
|
||||
<div
|
||||
flex="~ row items-center"
|
||||
rounded-lg p="2"
|
||||
bg="hover:primary/10"
|
||||
transition="~ all duration-150 ease-out"
|
||||
cursor="pointer"
|
||||
@click="stepBack"
|
||||
>
|
||||
<div i-lucide:step-back />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
flex="~ row items-start gap-1 grow"
|
||||
transition="~ all duration-150 ease-out"
|
||||
overflow="x-scroll"
|
||||
bg="primary/5" w-full rounded-lg p-2
|
||||
>
|
||||
<TransitionGroup
|
||||
:css="false"
|
||||
@enter="enterAnimator"
|
||||
@leave="leaveAnimator"
|
||||
>
|
||||
<CharacterShowcase
|
||||
v-for="(c, i) in STATES[stateIndex]"
|
||||
:key="i"
|
||||
:value="c.value"
|
||||
:variant="c.variant"
|
||||
code-point
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div flex="~ row items-center justify-center gap-1 wrap" py-2 text-xs>
|
||||
<div font-semibold w="full md:auto" text="center md:unset">
|
||||
图例
|
||||
</div>
|
||||
<div
|
||||
b="~ 2 dotted primary/20"
|
||||
rounded-lg px-2
|
||||
flex="~ items-center justify-center shrink-0"
|
||||
transition="~ all duration-150 ease-out"
|
||||
>
|
||||
字符
|
||||
</div>
|
||||
<div
|
||||
b="~ 2 dashed primary/20"
|
||||
rounded-lg px-2
|
||||
flex="~ items-center justify-center shrink-0"
|
||||
transition="~ all duration-150 ease-out"
|
||||
>
|
||||
不完整字素簇
|
||||
</div>
|
||||
<div
|
||||
b="~ 2 solid primary/50"
|
||||
bg="primary/10"
|
||||
rounded-lg px-2
|
||||
flex="~ items-center justify-center shrink-0"
|
||||
transition="~ all duration-150 ease-out"
|
||||
>
|
||||
完整字素簇
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const { variant = 'default' } = defineProps<{
|
||||
value: string
|
||||
variant?: 'default' | 'dotted' | 'active' | 'connector'
|
||||
codePoint?: boolean
|
||||
invisibleCodePoint?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col items-center gap-1 justify-start items-center">
|
||||
<div
|
||||
b="~ 2"
|
||||
:class="{
|
||||
'b-solid b-primary/50 bg-primary/10 w-10': variant === 'active',
|
||||
'b-dotted b-primary/20 w-10': variant === 'dotted',
|
||||
'b-dashed b-primary/20 w-10': variant === 'default',
|
||||
'b-transparent bg-transparent': variant === 'connector',
|
||||
}"
|
||||
h-10 rounded-lg text-lg
|
||||
flex="~ items-center justify-center"
|
||||
transition="~ all duration-150 ease-out"
|
||||
>
|
||||
{{ value }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="codePoint || invisibleCodePoint"
|
||||
text-xs text="primary" font-mono
|
||||
flex="~ col items-center justify-center"
|
||||
:class="{ invisible: invisibleCodePoint }"
|
||||
>
|
||||
<div v-for="char in value" :key="char">
|
||||
{{ char.codePointAt(0)?.toString(16).toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import CharacterShowcase from './CharacterShowcase.vue'
|
||||
|
||||
defineProps<{
|
||||
characters: string[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ row justify-center items-start gap-1">
|
||||
<template v-for="(char, i) in characters" :key="i">
|
||||
<CharacterShowcase
|
||||
:value="char"
|
||||
code-point
|
||||
/>
|
||||
<CharacterShowcase
|
||||
v-if="i < characters.length - 1"
|
||||
value="+"
|
||||
invisible-code-point
|
||||
variant="connector"
|
||||
/>
|
||||
</template>
|
||||
<CharacterShowcase
|
||||
value="="
|
||||
invisible-code-point
|
||||
variant="connector"
|
||||
/>
|
||||
<CharacterShowcase
|
||||
:value="characters.join('')"
|
||||
code-point
|
||||
variant="active"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { animate } from 'animejs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import CharacterShowcase from './CharacterShowcase.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
initText: string
|
||||
}>()
|
||||
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
|
||||
const text = ref(props.initText)
|
||||
const highlightedClusterIndex = ref(-1)
|
||||
|
||||
const segments = computed(() => [...segmenter.segment(text.value)])
|
||||
|
||||
function enterAnimator(e: Element, done: () => void) {
|
||||
return animate(e, {
|
||||
opacity: [0, 1],
|
||||
scale: [0.5, 1],
|
||||
ease: 'outQuad',
|
||||
duration: 200,
|
||||
onComplete: done,
|
||||
})
|
||||
}
|
||||
|
||||
function leaveAnimator(e: Element, done: () => void) {
|
||||
return animate(e, {
|
||||
opacity: [1, 0],
|
||||
scale: [1, 0.5],
|
||||
ease: 'outQuad',
|
||||
duration: 200,
|
||||
onComplete: done,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full" grid="~ cols-[auto] md:cols-[min-content_auto]" overflow-hidden rounded-lg>
|
||||
<div
|
||||
bg="primary/5"
|
||||
flex="~ items-center justify-start md:justify-end"
|
||||
p="2 md:e-0" text-sm font-semibold
|
||||
>
|
||||
文本
|
||||
</div>
|
||||
<div bg="primary/5" p-2>
|
||||
<input v-model="text" name="text" bg="primary/10" w-full rounded-lg p-2 text="md:lg">
|
||||
</div>
|
||||
|
||||
<div
|
||||
whitespace-nowrap bg="primary/10"
|
||||
flex="~ items-center justify-start md:justify-end"
|
||||
p="2 md:e-0" text-sm font-semibold
|
||||
>
|
||||
字素簇
|
||||
</div>
|
||||
<div bg="primary/10" flex="~ row gap-2 wrap" p-2>
|
||||
<TransitionGroup
|
||||
:css="false"
|
||||
@enter="enterAnimator"
|
||||
@leave="leaveAnimator"
|
||||
>
|
||||
<CharacterShowcase
|
||||
v-for="(segment, segIndex) in segments"
|
||||
:key="segIndex"
|
||||
:variant="highlightedClusterIndex === segIndex ? 'active' : 'default'"
|
||||
cursor-pointer
|
||||
:value="segment.segment"
|
||||
@mouseover="highlightedClusterIndex = segIndex"
|
||||
@mouseleave="highlightedClusterIndex = -1"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<div
|
||||
whitespace-nowrap bg="primary/15"
|
||||
flex="~ items-center justify-start md:justify-end"
|
||||
p="2 md:e-0" text-sm font-semibold
|
||||
>
|
||||
字符
|
||||
</div>
|
||||
<div bg="primary/15" flex="~ row gap-2 wrap" p-2>
|
||||
<TransitionGroup
|
||||
:css="false"
|
||||
@enter="enterAnimator"
|
||||
@leave="leaveAnimator"
|
||||
>
|
||||
<template v-for="(segment, segIndex) in segments" :key="segIndex">
|
||||
<CharacterShowcase
|
||||
v-for="(cp, cpIndex) in [...segment.segment]"
|
||||
:key="cpIndex"
|
||||
:variant="highlightedClusterIndex === segIndex ? 'active' : 'default'"
|
||||
:value="cp"
|
||||
code-point
|
||||
cursor-pointer
|
||||
@mouseover="highlightedClusterIndex = segIndex" @mouseleave="highlightedClusterIndex = -1"
|
||||
/>
|
||||
</template>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { TextSplitter, Timeline } from 'animejs'
|
||||
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { onMounted, shallowRef, useTemplateRef, watchEffect } from 'vue'
|
||||
|
||||
const animatedText = useTemplateRef('animatedText')
|
||||
const shouldReduceMotion = useLocalStorage('docs:settings/reduce-motion', false) // A11y-friendly!
|
||||
|
||||
const animatedChars = shallowRef<TextSplitter['chars']>()
|
||||
const timeline = shallowRef<Timeline>()
|
||||
|
||||
onMounted(async () => {
|
||||
const { createTimeline, stagger, text } = await import('animejs')
|
||||
|
||||
const { chars } = text.split(animatedText.value!, {
|
||||
chars: { wrap: 'clip', clone: 'bottom' },
|
||||
accessible: true,
|
||||
})
|
||||
animatedChars.value = chars
|
||||
|
||||
timeline.value = createTimeline({
|
||||
loop: true,
|
||||
defaults: { ease: 'inOut(3)', duration: 650 },
|
||||
})
|
||||
.add(chars, {
|
||||
y: '-100%',
|
||||
opacity: [1, 0, 1],
|
||||
loop: true,
|
||||
loopDelay: 350,
|
||||
duration: 1000,
|
||||
ease: 'inOut(2)',
|
||||
}, stagger(150, { from: 'random' }))
|
||||
.reset()
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (shouldReduceMotion.value) {
|
||||
timeline.value?.reset()
|
||||
}
|
||||
else {
|
||||
timeline.value?.play()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot name="before" :motion-reduced="shouldReduceMotion" />
|
||||
|
||||
<div class="relative" v-bind="$attrs">
|
||||
<div ref="animatedText">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="absolute left-0 top-0 op-20" aria-hidden>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="after" :motion-reduced="shouldReduceMotion" />
|
||||
</template>
|
||||
Binary file not shown.
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: DevLog @ 2025.08.01
|
||||
category: DevLog
|
||||
date: 2025-08-01
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import CharacterMatcher from './CharacterMatcher.vue'
|
||||
import GraphemeClusterAssembler from './GraphemeClusterAssembler.vue'
|
||||
import GraphemeClusterInspector from './GraphemeClusterInspector.vue'
|
||||
import RollingText from './RollingText.vue'
|
||||
</script>
|
||||
|
||||
## 开始之前
|
||||
|
||||
<RollingText text-2xl>
|
||||
你好~我是 Makito
|
||||
|
||||
<template #before="{ motionReduced }">
|
||||
<div text-sm>
|
||||
<template v-if="!motionReduced">
|
||||
|
||||
> 下方动画效果可通过右上角的“减少动画”开关控制
|
||||
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
> **下方动画效果已关闭** <br />
|
||||
> 可以通过右上角的“减少动画”开关重新开启动画
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</RollingText>
|
||||
|
||||
漫无止境的八月开始了,也许可以用这道[有真实感的数学问题](https://oeis.org/A180632/a180632.pdf)消磨时光。抱歉……跑题了。
|
||||
|
||||
虽然我已经参与 Project AIRI 很久了,但这还是我第一次在 DevLog 上发文。
|
||||
|
||||
在这篇文章中,我会分享我在 AIRI 中实现文本动画的过程,以及如何构建一个从 UTF-8 字节流中边接收边读出「字素簇」(grapheme cluster)的库。希望对你有所启发!
|
||||
|
||||
## 背景
|
||||
|
||||
最近,[Anime.js](https://animejs.com/) 在 v4.10 版本中发布了全新的[文字工具](https://animejs.com/documentation/text),为文本动画提供了一系列实用工具(如上方动画所示)。这次更新也补上了 Anime.js 在文本动画方向的空白。以前,我需要手动把文本拆分成单个字符来做动画,或者依赖像 [splt](https://www.spltjs.com/)(底层用的也是 Anime.js)这样的库,或者在 [GSAP](https://gsap.com/) 中使用 [SplitText](https://gsap.com/docs/v3/Plugins/SplitText/) 插件。
|
||||
|
||||
文本动画能够让聊天消息在 UI 中以更炫酷的方式出现。一般来说,消息收到即是完整的,所以我们只需要把收到的文本按字符拆分后做动画即可。
|
||||
|
||||
在 Project AIRI 里,我们的伙伴 [@nekomeowww](https://github.com/nekomeowww) 也做了一个丝滑的聊天气泡组件:
|
||||
|
||||
<video controls muted autoplay loop max-w="500px" w-full mx-auto>
|
||||
<source src="./assets/animated-chat-bubble.mp4">
|
||||
</video>
|
||||
|
||||
<div text-sm text-center>
|
||||
|
||||
欢迎来[我们的 UI storybook](https://airi.moeru.ai/ui/#/story/src-components-gadgets-chatbubbleminimalism-story-vue?variantId=chat) 看看
|
||||
|
||||
</div>
|
||||
|
||||
但如果我们想要读取 UTF-8 字节流,实时地给收到的文本加上动画效果呢?这在实时应用场景中很常见,比如聊天或语音转写应用,这类应用的 UI 需要边接收边逐字显示内容。
|
||||
|
||||
## 「字」的边界感
|
||||
|
||||
在这种场景下,什么才算「字」?在 Unicode 里,最小的有意义文本单位通常是[码点](https://www.unicode.org/versions/Unicode14.0.0/ch02.pdf#G25564)(Code point)。但在编码层面,尤其是 UTF-8,一个码点可能由多个字节组成。例如日文假名「あ」对应码点 `U+3042`,在 UTF-8 下编码为 `0xE3 0x81 0x82`。也就是说,在读取字节流时,只有所有字节都到齐的情况下,才能还原出完整字符。
|
||||
|
||||
别担心,我们还有 Web API 的 [TextDecoder](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder) 可以用。用 `TextDecoder.decode` 并加上 `stream` 选项,解码器就会自动处理流式到达的数据,并正确还原出字符:
|
||||
|
||||
```javascript
|
||||
const decoder = new TextDecoder()
|
||||
const decoded = decoder.decode(chunk, { stream: true })
|
||||
```
|
||||
|
||||
## 这样安全吗?
|
||||
|
||||
太长不看:**不的**。
|
||||
|
||||
TextDecoder 的确能帮我们把字节流正确解码成 Unicode 码点(字符)。但在 Unicode 里,还有「字素簇」(grapheme cluster)这个概念,它把多个码点组合成一个「视觉上」一体的字符。例如「👩👩👧👦」(家庭)这个 Emoji,底层其实由多个码点组成,但视觉上是一个字符。它们之间通过零宽连接符(ZWJ,码点 `U+200D`)连接。
|
||||
|
||||
这可能有点难以理解。不过别担心,我做了一个交互式的小组件,来帮助你探索字素簇和码点的组合方式。可以留意拆分结果里的 `200D` 码点:
|
||||
|
||||
<GraphemeClusterInspector initText="👩👩👧👦🏄♀️🤼♂️🙋♀️" />
|
||||
|
||||
<div text-sm text-center>
|
||||
|
||||
可以把鼠标悬停在字素簇或字符上,看看它们是如何组合的,也可以输入任意文本。
|
||||
|
||||
</div>
|
||||
|
||||
类似 Emoji,一些语言也会用组合码点来构造复杂的字符。例如泰米尔语的「நி」(ni),由基础字符「ந」(na)和组合元音「 ி」(i)组成。它们组合后,就变成了一个整体的「நி」字素簇。我们把类似的字素簇拆分看看:
|
||||
|
||||
<GraphemeClusterInspector initText="நிกำषिक्षि" /> <!-- cSpell:disable-line -->
|
||||
|
||||
## 构建一个「读取器」
|
||||
|
||||
对于固定长度的字符串,拆分字素簇其实很简单。但在流式场景下,我们面对的是一个不断流出字节的「管道」,最极端时每次只收到一个字节。而且由于 UTF-8 的特性,我们无法假设收到的字节一定能构成完整码点(一个码点最多 4 字节)。
|
||||
|
||||
为了解决这个问题,我们可以用前面提到的 [TextDecoder](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder)。每次收到并解码后,把解码出来的字符串拼接到缓冲区,字素簇就能自然地组装起来。
|
||||
|
||||
现在我们已经能把字节拼成字符或是字素簇了,接下来就要考虑如何<b title="安全第一呀" underline="~ dotted" cursor-help>安全地</b>读取字素簇。好在我们还有 `Intl.Segmenter` 可以用,它是 Web API 提供的拆分字符串为字素簇的工具,并且支持多语言。 `Intl.Segmenter` 不只是字素簇的工具,它还可以根据你提供的选项把文本拆分成单词或句子。
|
||||
|
||||
假设我们收到了一些字节,正确解码后得到了如下字素簇:
|
||||
|
||||
<div flex="~ row items-center justify-center gap-1" overflow="x-scroll">
|
||||
<GraphemeClusterAssembler :characters="[...'👩👧']" />
|
||||
</div>
|
||||
|
||||
此时,「👩👧」(两个人)本身就是一个字素簇。我们能直接把它取出来,然后开始读取后续字节吗?哒咩。如果收到更多字节,前面的字素簇会变成「👩👧👦」(三个人):
|
||||
|
||||
<div flex="~ row items-center justify-center gap-1" overflow="x-scroll">
|
||||
<GraphemeClusterAssembler :characters="['👩👧', '', '👦']" />
|
||||
</div>
|
||||
|
||||
如果我们提前把「👩👧」输出,就会得到一个不完整的字素簇,这并不是我们想要的结果。
|
||||
|
||||
## 效率至上
|
||||
|
||||
有些场景下,我们希望可以尽早输出这些(当然是完整的)字素簇。我们依然用 `Intl.Segmenter`,但对出队(dequeuing)策略稍作调整:如果无法确定当前字素簇是否完整,就等下一个出现时,把除了最后一个以外的都输出:
|
||||
|
||||
```ts
|
||||
declare let clusterBuffer: string
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
while (true) {
|
||||
const segments = [...segmenter.segment(clusterBuffer)]
|
||||
segments.pop() // 丢弃最后一个字素簇
|
||||
for (const seg of segments) {
|
||||
yield seg.segment // 输出完整的字素簇
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这样的话,不完整的字素簇永远不会被提前输出,而是等下一个出现时才被处理。我也做了一个交互式的小组件来演示这个过程:
|
||||
|
||||
<CharacterMatcher />
|
||||
|
||||
<div text-sm text-center>
|
||||
|
||||
可以看到,我们会等到第二个字素簇出现后,才认为第一个是完整的。
|
||||
|
||||
</div>
|
||||
|
||||
## Clustr 的诞生
|
||||
|
||||
写这篇 DevLog 的时候,社区中已经有不少可以把字符串拆分成字素簇的库了。但我没找到一个既能接受 UTF-8 字节流、又能随到随输出字素簇的实现。所以我自己实现了一个,并把思路分享给了大家,并取名为 [Clustr](https://github.com/sumimakito/clustr),和 Unicode 的「字素簇」概念相应。
|
||||
|
||||
尽管它的核心代码不到 100 行,如果你也想在项目里把 UTF-8 字节流做成炫酷的文本动画(比如我们在 Project AIRI 里做的那样),它或许能帮到你。
|
||||
|
||||
如果你对 Project AIRI 感兴趣,也欢迎来我们的 GitHub 仓库 [moeru-ai/airi](https://github.com/moeru-ai/airi) 看看!
|
||||
+2
-1
@@ -29,6 +29,7 @@
|
||||
"vue-sonner": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.57",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.8",
|
||||
"@mdit/plugin-footnote": "^0.22.2",
|
||||
@@ -39,7 +40,7 @@
|
||||
"@types/markdown-it-anchor": "^7.0.0",
|
||||
"@unocss/reset": "^66.3.3",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"animejs": "^4.0.2",
|
||||
"animejs": "^4.1.1",
|
||||
"fast-glob": "^3.3.3",
|
||||
"gray-matter": "^4.0.3",
|
||||
"markdown-it": "^14.1.0",
|
||||
|
||||
+5
-1
@@ -30,10 +30,14 @@
|
||||
"components/**/*.vue",
|
||||
".vitepress/**/*.vue",
|
||||
"/**/*.ts",
|
||||
"/**/*.md",
|
||||
".vitepress/**/*.ts",
|
||||
".vitepress/**/*.vue"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
],
|
||||
"vueCompilerOptions": {
|
||||
"vitePressExtensions": [".md"]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
import { blackA, cyan, grass, green, indigo, mauve, purple, red, slate, teal, violet } from '@radix-ui/colors'
|
||||
import { defineConfig, presetAttributify, presetTypography, presetWebFonts, presetWind3, transformerDirectives, transformerVariantGroup } from 'unocss'
|
||||
import { defineConfig, presetAttributify, presetIcons, presetTypography, presetWebFonts, presetWind3, transformerDirectives, transformerVariantGroup } from 'unocss'
|
||||
|
||||
export default defineConfig({
|
||||
presets: [
|
||||
@@ -57,6 +57,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
}),
|
||||
presetIcons(),
|
||||
],
|
||||
content: {
|
||||
filesystem: [
|
||||
|
||||
Generated
+10
-2
@@ -1045,6 +1045,9 @@ importers:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
devDependencies:
|
||||
'@iconify-json/lucide':
|
||||
specifier: ^1.2.57
|
||||
version: 1.2.57
|
||||
'@iconify/vue':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(vue@3.5.17(typescript@5.8.3))
|
||||
@@ -1076,8 +1079,8 @@ importers:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0(typescript@5.8.3)(vue@3.5.17(typescript@5.8.3))
|
||||
animejs:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.2
|
||||
fast-glob:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
@@ -6543,6 +6546,9 @@ packages:
|
||||
animejs@4.0.2:
|
||||
resolution: {integrity: sha512-f0L/kSya2RF23iMSF/VO01pMmLwlAFoiQeNAvBXhEyLzIPd2/QTBRatwGUqkVCC6seaAJYzAkGir55N4SL+h3A==}
|
||||
|
||||
animejs@4.1.2:
|
||||
resolution: {integrity: sha512-QojQzHzN4ZCOGk4Seir5CWPHGKFPpMAsen3KEj/BHsIKDceH0xKd3FBWflyKiNefKG2fn3+ofpY9jD/UXhpY6A==}
|
||||
|
||||
ansi-align@3.0.1:
|
||||
resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
|
||||
|
||||
@@ -18403,6 +18409,8 @@ snapshots:
|
||||
|
||||
animejs@4.0.2: {}
|
||||
|
||||
animejs@4.1.2: {}
|
||||
|
||||
ansi-align@3.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
|
||||
Reference in New Issue
Block a user