docs(devlog): refactor and apply previous suggestions (#343)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Neko <neko@ayaka.moe>
Co-authored-by: 藍+85CD <50108258+kwaa@users.noreply.github.com>
This commit is contained in:
Makito
2025-08-01 23:18:11 +08:00
committed by GitHub
co-authored by gemini-code-assist[bot] Neko 藍+85CD
parent 6c6b77f924
commit 55a65e0ad9
10 changed files with 24 additions and 429 deletions
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { animate } from 'animejs'
import { useData } from 'vitepress'
import { ref, watchEffect } from 'vue'
import CharacterShowcase from './CharacterShowcase.vue'
@@ -29,6 +30,8 @@ const STATES: Character[][] = [
[{ value: '💆🏼‍♀️', variant: 'active' }, { value: '👩🏻‍💻', variant: 'active' }],
]
const { lang } = useData()
const stateIndex = ref(0)
const isPlaying = ref(true)
const animationHandle = ref<number>()
@@ -143,7 +146,7 @@ function stepBack() {
<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
{{ lang === 'zh-Hans' ? '图例' : 'Legend' }}
</div>
<div
b="~ 2 dotted primary/20"
@@ -151,7 +154,7 @@ function stepBack() {
flex="~ items-center justify-center shrink-0"
transition="~ all duration-150 ease-out"
>
Character
{{ lang === 'zh-Hans' ? '字符' : 'Character' }}
</div>
<div
b="~ 2 dashed primary/20"
@@ -159,7 +162,7 @@ function stepBack() {
flex="~ items-center justify-center shrink-0"
transition="~ all duration-150 ease-out"
>
Incomplete cluster
{{ lang === 'zh-Hans' ? '不完整字素簇' : 'Incomplete cluster' }}
</div>
<div
b="~ 2 solid primary/50"
@@ -168,7 +171,7 @@ function stepBack() {
flex="~ items-center justify-center shrink-0"
transition="~ all duration-150 ease-out"
>
Complete cluster
{{ lang === 'zh-Hans' ? '完整字素簇' : 'Complete cluster' }}
</div>
</div>
</div>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { animate } from 'animejs'
import { useData } from 'vitepress'
import { computed, ref } from 'vue'
import CharacterShowcase from './CharacterShowcase.vue'
@@ -8,6 +9,8 @@ const props = defineProps<{
initText: string
}>()
const { lang } = useData()
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
const text = ref(props.initText)
@@ -43,7 +46,7 @@ function leaveAnimator(e: Element, done: () => void) {
flex="~ items-center justify-start md:justify-end"
p="2 md:e-0" text-sm font-semibold
>
Text
{{ lang === 'zh-Hans' ? '文本' : '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">
@@ -54,7 +57,7 @@ function leaveAnimator(e: Element, done: () => void) {
flex="~ items-center justify-start md:justify-end"
p="2 md:e-0" text-sm font-semibold
>
Grapheme clusters
{{ lang === 'zh-Hans' ? '字素簇' : 'Grapheme clusters' }}
</div>
<div bg="primary/10" flex="~ row gap-2 wrap" p-2>
<TransitionGroup
@@ -79,7 +82,7 @@ function leaveAnimator(e: Element, done: () => void) {
flex="~ items-center justify-start md:justify-end"
p="2 md:e-0" text-sm font-semibold
>
Characters
{{ lang === 'zh-Hans' ? '字符' : 'Characters' }}
</div>
<div bg="primary/15" flex="~ row gap-2 wrap" p-2>
<TransitionGroup
@@ -90,7 +93,7 @@ function leaveAnimator(e: Element, done: () => void) {
<template v-for="(segment, segIndex) in segments" :key="segIndex">
<CharacterShowcase
v-for="(cp, cpIndex) in [...segment.segment]"
:key="cpIndex"
:key="`${segIndex}-${cpIndex}`"
:variant="highlightedClusterIndex === segIndex ? 'active' : 'default'"
:value="cp"
code-point
@@ -138,7 +138,7 @@ You may see how we wait until the second grapheme cluster to appear before emitt
</div>
## Introducing Clustr
## Introducing [Clustr](https://github.com/sumimakito/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.
@@ -1,175 +0,0 @@
<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>
@@ -1,38 +0,0 @@
<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>
@@ -1,34 +0,0 @@
<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>
@@ -1,104 +0,0 @@
<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>
@@ -1,60 +0,0 @@
<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>
@@ -5,10 +5,10 @@ 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'
import CharacterMatcher from '../../../en/blog/DevLog-2025.08.01/CharacterMatcher.vue'
import GraphemeClusterAssembler from '../../../en/blog/DevLog-2025.08.01/GraphemeClusterAssembler.vue'
import GraphemeClusterInspector from '../../../en/blog/DevLog-2025.08.01/GraphemeClusterInspector.vue'
import RollingText from '../../../en/blog/DevLog-2025.08.01/RollingText.vue'
</script>
## 开始之前
@@ -20,13 +20,13 @@ import RollingText from './RollingText.vue'
<div text-sm>
<template v-if="!motionReduced">
> 下方动画效果可通过右上角的减少动画开关控制
> 下方动画效果可通过右上角的减少动画开关控制
</template>
<template v-else>
> **下方动画效果已关闭** <br />
> 可以通过右上角的减少动画开关重新开启动画
> 可以通过右上角的减少动画开关重新开启动画
</template>
</div>
@@ -48,7 +48,7 @@ import RollingText from './RollingText.vue'
在 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">
<source src="../../../en/blog/DevLog-2025.08.01/assets/animated-chat-bubble.mp4">
</video>
<div text-sm text-center>
@@ -72,7 +72,7 @@ const decoded = decoder.decode(chunk, { stream: true })
## 这样安全吗?
太长不看:**不**。
太长不看:**不**。
TextDecoder 的确能帮我们把字节流正确解码成 Unicode 码点(字符)。但在 Unicode 里,还有「字素簇」(grapheme cluster)这个概念,它把多个码点组合成一个「视觉上」一体的字符。例如「👩‍👩‍👧‍👦」(家庭)这个 Emoji,底层其实由多个码点组成,但视觉上是一个字符。它们之间通过零宽连接符(ZWJ,码点 `U+200D`)连接。
@@ -138,7 +138,7 @@ while (true) {
</div>
## Clustr 的诞生
## [Clustr](https://github.com/sumimakito/clustr) 的诞生
写这篇 DevLog 的时候,社区中已经有不少可以把字符串拆分成字素簇的库了。但我没找到一个既能接受 UTF-8 字节流、又能随到随输出字素簇的实现。所以我自己实现了一个,并把思路分享给了大家,并取名为 [Clustr](https://github.com/sumimakito/clustr),和 Unicode 的「字素簇」概念相应。