mirror of
https://github.com/moeru-ai/airi.git
synced 2026-10-31 03:44:38 +08:00
feat(stage-ui,stage-tamagotchi): fade live voice text in and animate rewrites (#2998)
This commit is contained in:
@@ -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>
|
||||
|
||||
+40
@@ -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>
|
||||
Reference in New Issue
Block a user