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:
co-authored by
gemini-code-assist[bot]
Neko
藍+85CD
parent
6c6b77f924
commit
55a65e0ad9
@@ -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>
|
||||
Binary file not shown.
@@ -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 的「字素簇」概念相应。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user