feat(stage-ui,stage-tamagotchi): fade live voice text in and animate rewrites (#2998)

This commit is contained in:
Neko
2026-10-17 14:24:31 +08:00
committed by GitHub
parent 99dce26f98
commit efa42d2ebd
13 changed files with 515 additions and 21 deletions
+5 -1
View File
@@ -208,10 +208,14 @@ Use an opaque background for swipe content. If the visible label is hidden, pres
| [TransitionHorizontal](./packages/ui/src/components/animations/transition-horizontal.vue) | 500ms width and opacity transition |
| [AnimatedContent](./packages/ui/src/components/animations/animated-content.vue) | 220ms enter, 160ms exit, and 6px inner blur transition |
| Shared UnoCSS animations | Overlay 300ms, content 150ms, directional entrance 400ms, fade 200ms |
| [Blur fade](./packages/stage-ui/src/components/widgets/poppin-text/animators/blur-fade.ts) | Each grapheme fades and sharpens from 3px blur over 500ms. Starts are 40ms apart, at most 600ms in total |
| [RewriteText](./packages/stage-ui/src/components/widgets/rewrite-text/rewrite-text.vue) | Changed words blur out, then the new words blur in, with the blur fade timing |
`AnimatedContent` owns motion, not surface styles. Its lifecycle owner supplies `data-state` and keeps the node mounted until the exit animation ends.
`AnimatedContent` and `BottomDrawer` explicitly handle reduced-motion preferences. Check other controls individually instead of assuming library-wide support.
Live voice text appears with the blur fade through `PoppinText`. A rewrite of spoken text, for example by the rephrase plugin, plays `RewriteText`, so only the changed words move.
`AnimatedContent`, `BottomDrawer`, and the blur fade explicitly handle reduced-motion preferences. Check other controls individually instead of assuming library-wide support.
Icons use Iconify. Existing components and stories use sets such as Solar and Phosphor. The repository does not require one exclusive icon set.
@@ -141,6 +141,23 @@ describe('voice inlay', () => {
await expect.poll(inlayView, { timeout: 3000 }).toBe('hidden')
})
it('plays the rewrite of auto-sent speech on the sent card before the card moves out', async () => {
const view = renderInlay()
publish({ drafts: [], input: { ...capturing, text: 'hello their', segments: [{ id: 'speech-0', text: 'hello their', final: true }] } })
await expect.element(view.getByTestId('voice-draft-live')).toHaveTextContent('hello their')
// Auto send: the speech settles into a draft that the host sends at once.
publish({ drafts: [{ ...draft, sending: true }], frontDraftId: 'draft' })
await expect.poll(inlayView).toBe('sent')
const card = document.querySelector('[data-testid="voice-inlay"]')!
expect(card.classList.contains('inlay-send')).toBe(false)
await expect.element(view.getByRole('status')).toHaveTextContent('Hello there.')
publish({ drafts: [] })
await expect.poll(() => card.classList.contains('inlay-send'), { timeout: 3000 }).toBe(true)
await expect.poll(inlayView, { timeout: 3000 }).toBe('hidden')
})
it('keeps new speech that starts during the send animation', async () => {
renderInlay()
publish({ drafts: [{ ...draft, sending: true }] })
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { RewriteText } from '@proj-airi/stage-ui/components'
import { VoiceDrafts } from '@proj-airi/stage-ui/components/scenarios/chat'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
@@ -56,9 +57,30 @@ const sendingDraft = computed(() => controls.snapshot.drafts.find(draft => draft
/** The last text that went to the chat. It stays on the card while the card moves out, after the host removed the draft. */
const sentText = shallowRef('')
/**
* The text that live speech showed last. Auto send moves the card from live speech straight to the sent text, so a
* rewrite of the speech plays on the sent card. An editable draft in between already showed the rewritten text.
*/
const spokenText = shallowRef('')
/** The rewrite that the sent card plays before it moves out. Undefined when the sent text equals the spoken text. */
const sentRewrite = shallowRef<{ from: string, to: string }>()
watch(() => liveInput.value?.text, (value) => {
if (value?.trim())
spokenText.value = value.trim()
})
// Speech that settled into an editable draft showed its rewrite in the draft. A later manual send does not replay it.
watch(() => !liveInput.value && editableDrafts.value.length > 0, (settledIntoDraft) => {
if (settledIntoDraft)
spokenText.value = ''
})
watch(sendingDraft, (draft) => {
if (draft)
sentText.value = draft.text
if (!draft)
return
sentText.value = draft.text
const from = spokenText.value
spokenText.value = ''
sentRewrite.value = from && from !== draft.text.trim() ? { from, to: draft.text.trim() } : undefined
})
const view = computed<InlayView>(() => {
@@ -158,7 +180,7 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
'bg-white/95 dark:bg-neutral-900/95',
'transition-[width,height,border-radius] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none',
shown === 'capsule' ? 'h-11 w-44 rounded-full' : 'h-40 w-full max-w-110 rounded-3xl',
shown === 'sent' ? 'inlay-send' : '',
shown === 'sent' && !sentRewrite ? 'inlay-send' : '',
]"
@animationend="sending = false"
>
@@ -188,7 +210,10 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
<span aria-hidden="true" :class="['i-solar:arrow-up-outline size-3.5 text-primary-500']" />
{{ t('stage.chat.voice-draft.sent') }}
</span>
<p :class="['m-0 line-clamp-4 whitespace-pre-wrap break-words text-[15px] font-medium leading-relaxed']" v-text="sentText" />
<p v-if="sentRewrite" :class="['m-0 line-clamp-4 whitespace-pre-wrap break-words text-[15px] font-medium leading-relaxed']">
<RewriteText :from="sentRewrite.from" :to="sentRewrite.to" @finished="sentRewrite = undefined" />
</p>
<p v-else :class="['m-0 line-clamp-4 whitespace-pre-wrap break-words text-[15px] font-medium leading-relaxed']" v-text="sentText" />
</div>
<VoiceDrafts v-else variant="composer" show-silent-speech>
<template #hint>
@@ -80,6 +80,46 @@ describe('voiceDrafts composer variant', () => {
expect(shown).toEqual([false, true])
})
it('plays the rewrite when live speech settles into a draft with other text, then shows the textarea', async () => {
const view = renderComposer()
const context = getSpeechBusContext()
context.emit(voiceSnapshotChanged, {
connected: true,
drafts: [],
input: { requestId: 'speech', sessionId: 'alice', phase: 'capturing', text: 'turn on the lite', segments: [{ id: 'speech-0', text: 'turn on the lite', final: true }] },
})
await expect.element(view.getByTestId('voice-draft-live')).toHaveTextContent('turn on the lite')
context.emit(voiceSnapshotChanged, {
connected: true,
drafts: [{ id: 'draft', sessionId: 'alice', rawText: 'turn on the lite', text: 'Turn on the light.' }],
frontDraftId: 'draft',
})
await expect.element(view.getByTestId('voice-draft-rewrite')).toBeInTheDocument()
await expect.element(view.getByRole('textbox', { name: 'Voice draft' })).toHaveValue('Turn on the light.')
expect(view.getByTestId('voice-draft-rewrite').query()).toBeNull()
})
it('rewrites from the spoken paragraph when the host merges the draft before the input settles', async () => {
const view = renderComposer()
const context = getSpeechBusContext()
const draft = { id: 'draft', sessionId: 'alice', rawText: 'first', text: 'First sentence.' }
const speech = { requestId: 'speech', sessionId: 'alice', text: 'turn on the lite', segments: [{ id: 'speech-0', text: 'turn on the lite', final: true }] }
context.emit(voiceSnapshotChanged, { connected: true, drafts: [draft], frontDraftId: 'draft', input: { ...speech, phase: 'capturing' } })
await expect.element(view.getByTestId('voice-draft-live')).toHaveTextContent('First sentence. turn on the lite')
// The host stores the merged draft first, while the input still finalizes. The input settles in a later snapshot.
const merged = { ...draft, text: 'First sentence. Turn on the light.' }
context.emit(voiceSnapshotChanged, { connected: true, drafts: [merged], frontDraftId: 'draft', input: { ...speech, phase: 'finalizing' } })
await expect.element(view.getByTestId('voice-draft-live')).toHaveTextContent('First sentence. turn on the lite')
context.emit(voiceSnapshotChanged, { connected: true, drafts: [merged], frontDraftId: 'draft' })
// While old words leave, the paragraph is the spoken one, not the merged draft followed by the speech again.
await expect.element(view.getByTestId('voice-draft-rewrite')).toHaveTextContent('First sentence. turn on the lite')
await expect.element(view.getByRole('textbox', { name: 'Voice draft' })).toHaveValue('First sentence. Turn on the light.')
})
it('hides a draft while the host sends it and reports that nothing is shown', async () => {
const presence: boolean[] = []
const view = renderComposer({ onPresence: (visible: boolean) => presence.push(visible) })
@@ -3,12 +3,16 @@ import type { VoiceDraft } from '../../../../services/speech/bus'
import { joinTranscriptSegments } from '@proj-airi/provider-inference'
import { BasicButton, BasicTextarea, Button, GhostButton } from '@proj-airi/ui'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { computed, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import PoppinText from '../../../widgets/poppin-text/PoppinText.web.vue'
import RewriteText from '../../../widgets/rewrite-text/rewrite-text.vue'
import { useChatSessionStore } from '../../../../stores/chat/session-store'
import { useAiriCardStore } from '../../../../stores/modules/airi-card'
import { useVoiceControlsStore } from '../../../../stores/voice-controls'
import { createBlurFadeAnimator } from '../../../widgets/poppin-text/animators'
const props = withDefaults(defineProps<{
/**
@@ -96,14 +100,46 @@ const liveSegments = computed(() => {
tier: segment.final ? 'final' as const : index === latest ? 'latest' as const : 'interim' as const,
}))
})
/**
* The draft text when the current speech began. The host merges the speech into the draft one snapshot before the
* input settles. The live paragraph keeps this text, so the speech does not show twice in that snapshot.
*/
const spokenBase = shallowRef('')
watch(() => liveInput.value?.requestId, (requestId, previous) => {
if (requestId && requestId !== previous)
spokenBase.value = text.value.trim()
}, { immediate: true })
const liveSeparator = computed(() => {
const base = text.value.trim()
const base = spokenBase.value
const live = liveInput.value?.text.trim() ?? ''
if (!base || !live)
return ''
return joinTranscriptSegments([base, live]).length > base.length + live.length ? ' ' : ''
})
/** New transcript graphemes fade in. One animator for the component, because a new animator replays all text. */
const appear = createBlurFadeAnimator()
/**
* The rewrite that plays when live speech settles into a draft with other text, for example after the rephrase
* plugin. Only the changed words animate. The textarea returns when the animation finishes, and new speech drops it.
*/
const rewrite = shallowRef<{ from: string, to: string }>()
watch(liveInput, (input, previous) => {
if (input) {
rewrite.value = undefined
return
}
if (!previous)
return
// The last live paragraph, joined by the same rule as on screen. It is built once, when the speech settles.
const from = joinTranscriptSegments([spokenBase.value, previous.text.trim()]).trim()
const to = text.value.trim()
rewrite.value = from && to && from !== to ? { from, to } : undefined
})
const visible = computed(() => !!draft.value || !!liveInput.value)
watch(visible, value => emit('presence', value), { immediate: true })
@@ -204,19 +240,26 @@ function handleKeydown(event: KeyboardEvent) {
>
<!-- v-text keeps template whitespace out of this pre-wrap block. -->
<p v-if="liveInput" data-testid="voice-draft-live" :class="['m-0 whitespace-pre-wrap break-words']">
<span v-if="text.trim()" v-text="text.trim() + liveSeparator" />
<span
<span v-if="spokenBase" v-text="spokenBase + liveSeparator" />
<!-- Keyed by grapheme position, so a provider revision of shown text does not replay it. -->
<PoppinText
v-for="segment in liveSegments"
:key="segment.id"
as="span"
inline
:text="[{ key: segment.id, text: segment.text }]"
:animator="appear"
:data-tier="segment.tier"
:class="[
'transition-colors duration-300 motion-reduce:transition-none',
segment.tier === 'interim' ? 'text-primary-600/60 dark:text-primary-300/60' : '',
segment.tier === 'latest' ? 'text-primary-600 dark:text-primary-300' : '',
]"
v-text="segment.text"
/>
</p>
<p v-else-if="rewrite" data-testid="voice-draft-rewrite" :class="['m-0 whitespace-pre-wrap break-words']">
<RewriteText :from="rewrite.from" :to="rewrite.to" @finished="rewrite = undefined" />
</p>
<BasicTextarea
v-else-if="draft"
v-model="text"
@@ -2,3 +2,4 @@ export { default as ColorPalette } from './ColorPalette.vue'
export * from './poppin-text/animators'
export { default as PoppinText } from './poppin-text/PoppinText.web.vue'
export { default as PoppingSubtitles } from './PoppingSubtitles.web.vue'
export { default as RewriteText } from './rewrite-text/rewrite-text.vue'
@@ -72,4 +72,20 @@ describe('poppin text', () => {
app.unmount()
host.remove()
})
it('renders inline graphemes inside the chosen root element', async () => {
const host = document.createElement('p')
document.body.appendChild(host)
const app = createApp({ render: () => h(PoppinText, { text: [{ key: 'one', text: 'Hi' }], as: 'span', inline: true }) })
app.mount(host)
await nextTick()
const root = host.firstElementChild!
expect(root.tagName).toBe('SPAN')
expect(root.textContent).toBe('Hi')
expect(Array.from(root.children).every(span => !span.classList.contains('inline-block'))).toBe(true)
app.unmount()
host.remove()
})
})
@@ -24,6 +24,17 @@ const props = defineProps<{
text?: string | PoppinTextSegment[] | ReadableStream<Uint8Array>
textClass?: string | string[]
animator?: Animator
/**
* The root element tag. Use `span` to place the text inside a paragraph.
* @default 'div'
*/
as?: string
/**
* Renders each grapheme as an inline box that inherits the text color, so the text wraps by words like a paragraph.
* Without it, each grapheme is an inline block in the theme color, so transform animations can move it.
* @default false
*/
inline?: boolean
}>()
const emits = defineEmits<{
@@ -148,21 +159,16 @@ watch([targets, () => props.animator], ([targets, animator]) => {
</script>
<template>
<div>
<component :is="props.as ?? 'div'">
<span
v-for="target in targets"
:key="target.id"
ref="elements"
class="inline-block whitespace-pre-wrap color-primary-400 dark:color-primary-100"
:class="[
...(
typeof props.textClass === 'string'
? [props.textClass]
: (props.textClass || [])
),
'whitespace-pre-wrap',
props.inline ? '' : 'inline-block color-primary-400 dark:color-primary-100',
...(typeof props.textClass === 'string' ? [props.textClass] : (props.textClass || [])),
]"
>
{{ target.grapheme }}
</span>
</div>
>{{ target.grapheme }}</span>
</component>
</template>
@@ -0,0 +1,97 @@
import type { Animator } from '.'
import { animate, cubicBezier, stagger } from 'animejs'
/**
* The blurred stagger of AIRI text motion. The values match the slide transitions of the project talks.
*
* Each grapheme fades and sharpens over `durationMs`. Graphemes start `staggerMs` apart, and all starts fit in
* `maxSpreadMs`, so a long text does not take seconds to appear.
*/
export const BLUR_FADE = {
/** @default 500 */
durationMs: 500,
/** @default 40 */
staggerMs: 40,
/** @default 600 */
maxSpreadMs: 600,
/** @default 3 */
blurPx: 3,
} as const
export type BlurFadeOptions = Partial<Record<keyof typeof BLUR_FADE, number>>
const ease = cubicBezier(0.22, 1, 0.36, 1)
function prefersReducedMotion() {
return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
}
/** Sets the final state at once. An entered grapheme keeps no inline style, so it renders like plain text. */
function settle(elements: HTMLElement[], direction: 'in' | 'out', blurPx: number) {
for (const element of elements) {
element.style.opacity = direction === 'in' ? '' : '0'
element.style.filter = direction === 'in' ? '' : `blur(${blurPx}px)`
}
}
/**
* Fades graphemes in or out, one after another, with a blur.
*
* The start state is set before this function returns, so an entering grapheme never paints unblurred first.
* With reduced motion, the final state is set at once.
*
* Returns:
* - `finished`, which resolves when the last grapheme reaches its final state.
* - `stop`, which jumps every grapheme to its final state.
*/
export function playBlurFade(elements: HTMLElement[], direction: 'in' | 'out', options: BlurFadeOptions = {}) {
const { durationMs, staggerMs, maxSpreadMs, blurPx } = { ...BLUR_FADE, ...options }
if (elements.length === 0 || prefersReducedMotion()) {
settle(elements, direction, blurPx)
return { finished: Promise.resolve(), stop: () => {} }
}
const visible = { opacity: 1, filter: 'blur(0px)' }
const hidden = { opacity: 0, filter: `blur(${blurPx}px)` }
const [from, to] = direction === 'in' ? [hidden, visible] : [visible, hidden]
for (const element of elements) {
element.style.opacity = String(from.opacity)
element.style.filter = from.filter
}
const animation = animate(elements, {
opacity: [from.opacity, to.opacity],
filter: [from.filter, to.filter],
duration: durationMs,
ease,
delay: stagger(Math.min(staggerMs, maxSpreadMs / elements.length)),
})
const finished = new Promise<void>((resolve) => {
animation.then(() => {
settle(elements, direction, blurPx)
resolve()
})
})
return {
finished,
stop: () => {
animation.complete()
settle(elements, direction, blurPx)
},
}
}
/**
* Creates a Poppin'Text animator that fades new graphemes in with a blurred stagger.
*
* @example
* <PoppinText :text="text" :animator="createBlurFadeAnimator()" />
*/
export function createBlurFadeAnimator(options: BlurFadeOptions = {}): Animator {
return (elements) => {
const { stop } = playBlurFade(elements, 'in', options)
return stop
}
}
@@ -8,6 +8,7 @@ export interface CreateAnimatorOptions {
export type Animator = (elements: HTMLElement[]) => (() => void)
export * from './blur-fade'
export * from './fade'
export * from './float'
export * from './popup'
@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-vue'
import RewriteText from './rewrite-text.vue'
/** Short motion keeps the test fast. The stagger and the blur follow the same code path as the default motion. */
const motion = { durationMs: 30, staggerMs: 5 }
describe('rewriteText', () => {
it('animates only the changed words, at every place, then shows the new text as plain text', async () => {
const onFinished = vi.fn()
const view = render(RewriteText, { props: { from: 'um turn on the lite please', to: 'Turn on the light, please.', motion, onFinished } })
const root = view.container.firstElementChild!
// While old words leave, unchanged words stay whole and each removed grapheme is its own span.
const leaving = Array.from(root.children).map(span => span.textContent)
expect(leaving.join('')).toBe('um turn on the lite please')
expect(leaving).toContain(' on the ')
expect(leaving.filter(text => text?.length === 1).join('')).toBe('um turnlite')
await vi.waitFor(() => expect(onFinished).toHaveBeenCalledOnce())
expect(root.textContent).toBe('Turn on the light, please.')
expect(root.children).toHaveLength(1)
expect(root.querySelectorAll('[style*="blur"]')).toHaveLength(0)
})
it('finishes without animated graphemes when the text did not change', async () => {
const onFinished = vi.fn()
const view = render(RewriteText, { props: { from: 'Hello.', to: 'Hello.', motion, onFinished } })
await vi.waitFor(() => expect(onFinished).toHaveBeenCalledOnce())
expect(view.container.textContent).toBe('Hello.')
expect(view.container.firstElementChild?.children).toHaveLength(1)
})
})
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { Button } from '@proj-airi/ui'
import { onBeforeUnmount, shallowRef } from 'vue'
import PoppinText from '../poppin-text/PoppinText.web.vue'
import RewriteText from './rewrite-text.vue'
import { createBlurFadeAnimator } from '../poppin-text/animators'
const appear = createBlurFadeAnimator()
/** Chunks that a streaming speech recognizer emits for one sentence. */
const chunks = ['um so ', 'can you ', 'turn on the ', 'lite in the ', 'living room']
const transcript = shallowRef('')
let timer: ReturnType<typeof setInterval> | undefined
function speak() {
clearInterval(timer)
transcript.value = ''
let index = 0
timer = setInterval(() => {
transcript.value += chunks[index] ?? ''
index += 1
if (index >= chunks.length)
clearInterval(timer)
}, 450)
}
onBeforeUnmount(() => clearInterval(timer))
const spoken = 'um so can you turn on the lite in the living room'
const rewritten = 'So, can you turn on the light in the living room?'
/** A new key mounts a new rewrite, so the animation plays again. */
const replay = shallowRef(0)
</script>
<template>
<Story title="Rewrite Text" group="widgets" :layout="{ type: 'single', iframe: false }">
<Variant id="live-transcript" title="Live transcript (blur fade)">
<div :class="['flex flex-col items-start gap-4 p-4']">
<Button label="Speak" @click="speak" />
<p :class="['m-0 max-w-110 whitespace-pre-wrap break-words text-[15px] font-medium leading-relaxed', 'text-neutral-800 dark:text-neutral-100']">
<PoppinText as="span" inline :text="[{ key: 'speech-0', text: transcript }]" :animator="appear" />
</p>
</div>
</Variant>
<Variant id="rewrite" title="Rewrite">
<div :class="['flex flex-col items-start gap-4 p-4']">
<Button label="Replay" @click="replay += 1" />
<p :class="['m-0 max-w-110 whitespace-pre-wrap break-words text-[15px] font-medium leading-relaxed', 'text-neutral-800 dark:text-neutral-100']">
<RewriteText :key="replay" :from="spoken" :to="rewritten" />
</p>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,154 @@
<script setup lang="ts">
import type { BlurFadeOptions } from '../poppin-text/animators'
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplateRef } from 'vue'
import { playBlurFade } from '../poppin-text/animators'
const props = defineProps<{
/** The text before the rewrite. */
from: string
/** The text after the rewrite. */
to: string
/**
* The root element tag.
* @default 'span'
*/
as?: string
motion?: BlurFadeOptions
}>()
const emit = defineEmits<{
/** The new text shows completely. It also fires at once when the texts are equal or motion is reduced. */
(e: 'finished'): void
}>()
interface Change {
readonly kind: 'equal' | 'removed' | 'added'
readonly text: string
}
// Word segmentation also splits Chinese and Japanese text into words, so a rewrite keeps unchanged words in place.
const words = new Intl.Segmenter(undefined, { granularity: 'word' })
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
/**
* Compares two texts word by word with the longest common subsequence. Runs of the same kind are merged.
* A rewrite usually changes words at several places, for example a filler word at the start and the punctuation at
* the end, so only these places animate.
*
* @example
* diffWords('um turn on the lite', 'Turn on the light.')
* // => removed 'um turn', added 'Turn', equal ' on the ', removed 'lite', added 'light.'
*/
function diffWords(from: string, to: string): Change[] {
const fromWords = Array.from(words.segment(from), segment => segment.segment)
const toWords = Array.from(words.segment(to), segment => segment.segment)
// The common start and end are equal by definition. A long draft usually changes only its newest sentence, so the
// table below covers only the changed middle, not the whole draft.
let start = 0
while (start < fromWords.length && start < toWords.length && fromWords[start] === toWords[start])
start += 1
let end = 0
while (end < fromWords.length - start && end < toWords.length - start && fromWords[fromWords.length - 1 - end] === toWords[toWords.length - 1 - end])
end += 1
const before = fromWords.slice(start, fromWords.length - end)
const after = toWords.slice(start, toWords.length - end)
// `common[i][j]` is the length of the longest common subsequence of `before[i..]` and `after[j..]`.
const common = Array.from({ length: before.length + 1 }, () => new Uint32Array(after.length + 1))
for (let i = before.length - 1; i >= 0; i--) {
for (let j = after.length - 1; j >= 0; j--)
common[i]![j] = before[i] === after[j] ? common[i + 1]![j + 1]! + 1 : Math.max(common[i + 1]![j]!, common[i]![j + 1]!)
}
const changes: Change[] = []
const push = (kind: Change['kind'], text: string) => {
const last = changes.at(-1)
if (last?.kind === kind)
changes[changes.length - 1] = { kind, text: last.text + text }
else if (text)
changes.push({ kind, text })
}
push('equal', fromWords.slice(0, start).join(''))
let i = 0
let j = 0
while (i < before.length || j < after.length) {
if (i < before.length && j < after.length && before[i] === after[j]) {
push('equal', before[i]!)
i += 1
j += 1
}
else if (j >= after.length || (i < before.length && common[i + 1]![j]! >= common[i]![j + 1]!)) {
push('removed', before[i]!)
i += 1
}
else {
push('added', after[j]!)
j += 1
}
}
push('equal', fromWords.slice(fromWords.length - end).join(''))
return changes
}
const changes = computed(() => diffWords(props.from, props.to))
/** The changes on screen in one phase. A changed run splits into graphemes, which animate in reading order. */
function visible(hidden: Change['kind']) {
return changes.value
.filter(change => change.kind !== hidden)
.map(change => ({ ...change, graphemes: change.kind === 'equal' ? [] : Array.from(graphemes.segment(change.text), segment => segment.segment) }))
}
/**
* - `leaving`: the old words of every change blur out, from the first change to the last.
* - `entering`: the new words blur in, in the same order.
* - `finished`: the new text shows as plain text.
*/
const phase = shallowRef<'leaving' | 'entering' | 'finished'>('leaving')
const elements = useTemplateRef<HTMLElement[]>('animated')
let stop: (() => void) | undefined
let unmounted = false
onMounted(async () => {
if (changes.value.some(change => change.kind === 'removed')) {
const leave = playBlurFade(elements.value ?? [], 'out', props.motion)
stop = leave.stop
await leave.finished
}
if (unmounted)
return
phase.value = 'entering'
await nextTick()
const enter = playBlurFade(elements.value ?? [], 'in', props.motion)
stop = enter.stop
await enter.finished
if (unmounted)
return
phase.value = 'finished'
emit('finished')
})
onBeforeUnmount(() => {
unmounted = true
stop?.()
})
</script>
<template>
<!-- v-text keeps template whitespace out of a pre-wrap parent. Vue removes the newlines between these tags. -->
<component :is="props.as ?? 'span'">
<span v-if="phase === 'finished'" v-text="props.to" />
<template v-else>
<template v-for="(change, index) in visible(phase === 'leaving' ? 'added' : 'removed')" :key="`${phase}:${index}`">
<span v-if="change.kind === 'equal'" v-text="change.text" />
<template v-else>
<span v-for="(grapheme, offset) in change.graphemes" :key="offset" ref="animated" v-text="grapheme" />
</template>
</template>
</template>
</component>
</template>