Improve avatar expression and drag interactions

This commit is contained in:
Eric Cappannelli 2026-08-14 15:44:12 -04:00 committed by smontlouis
parent 99300b3b30
commit 698d21feb1
7 changed files with 408 additions and 25 deletions

View File

@ -1,7 +1,7 @@
import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
import { Avatar, type AvatarController } from '@bible-strong/avatar-react'
import { Avatar } from '@bible-strong/avatar-react'
import '@bible-strong/avatar-react/styles.css'
import { StrictMode, useRef, useState } from 'react'
import { StrictMode, useState } from 'react'
import { createRoot } from 'react-dom/client'
import definitionJson from './strobi.avatar.json'
@ -10,39 +10,79 @@ import './styles.css'
const validation = validateAvatarDefinition(definitionJson)
if (!validation.ok) throw new Error(validation.errors[0]?.message)
const definition = validation.value as AvatarDefinition
const expressions = Object.keys(definition.expressions)
const horizontalPosition = (expression: string) =>
expression.includes('left') ? 'left' : expression.includes('right') ? 'right' : 'center'
const verticalPosition = (expression: string) =>
expression.includes('up') ? 'up' : expression.includes('down') ? 'down' : 'middle'
const expressionGroups = [
['up', 'left', 'Up left'],
['up', 'center', 'Up'],
['up', 'right', 'Up right'],
['middle', 'left', 'Left'],
['middle', 'center', 'Neutral'],
['middle', 'right', 'Right'],
['down', 'left', 'Down left'],
['down', 'center', 'Down'],
['down', 'right', 'Down right'],
].map(([vertical, horizontal, label]) => ({
key: `${vertical}-${horizontal}`,
label,
expressions: expressions.filter(
expression =>
verticalPosition(expression) === vertical && horizontalPosition(expression) === horizontal
),
}))
const formatExpressionName = (expression: string) =>
expression.replaceAll('-', ' ').replace(/\b\w/g, letter => letter.toUpperCase())
function Demo() {
const controller = useRef<AvatarController>(null)
const [status, setStatus] = useState('neutral')
const play = () => {
const result = controller.current?.play('idle')
setStatus(result?.ok ? 'idle' : (result?.error.code ?? 'unavailable'))
}
const setNeutral = () => {
const result = controller.current?.setExpression('neutral')
setStatus(result?.ok ? 'neutral' : (result?.error.code ?? 'unavailable'))
}
const [expression, setExpression] = useState('neutral')
return (
<main>
<h1>Avatar package consumer</h1>
<section className="avatar-host" aria-label="Embedded avatar example">
<Avatar
ref={controller}
definition={definition}
expression={expression}
size={240}
onExpressionChange={setStatus}
ariaLabel="Embedded Strobi avatar"
/>
</section>
<button type="button" onClick={play}>
Play idle
</button>
<button type="button" onClick={setNeutral}>
Set neutral
</button>
<output aria-live="polite">{status}</output>
<section className="expression-picker" aria-labelledby="expression-picker-title">
<div className="expression-picker__heading">
<h2 id="expression-picker-title">Available expressions ({expressions.length})</h2>
<output aria-live="polite">Active: {formatExpressionName(expression)}</output>
</div>
<div className="expression-picker__list">
{expressionGroups.map(group => (
<section
key={group.key}
className={`expression-picker__group expression-picker__group--${group.key}`}
>
<h3>{group.label}</h3>
<div className="expression-picker__group-list">
{group.expressions.map(key => (
<button
key={key}
type="button"
className={key === expression ? 'is-selected' : undefined}
aria-pressed={key === expression}
onClick={() => setExpression(key)}
>
{formatExpressionName(key)}
</button>
))}
</div>
</section>
))}
</div>
</section>
<Avatar
definition={definition}
expression={expression}
mode="floating"
draggable
size={128}

View File

@ -27,6 +27,136 @@ main {
background: white;
}
.expression-picker {
width: min(100%, 720px);
}
.expression-picker__heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
}
.expression-picker h2 {
margin: 0;
font-size: 1.125rem;
}
.expression-picker output {
color: #475569;
}
.expression-picker__list {
display: grid;
grid-template-areas:
'up-left up-center up-right'
'middle-left middle-center middle-right'
'down-left down-center down-right';
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: 1fr 3fr 1fr;
align-items: center;
aspect-ratio: 1;
min-height: 640px;
gap: 12px;
margin-top: 16px;
padding: 32px;
box-sizing: border-box;
border: 1px solid #cbd5e1;
border-radius: 50%;
background: radial-gradient(circle, #ffffff 0 28%, #eef2ff 28.25% 100%);
}
.expression-picker__group {
min-width: 0;
}
.expression-picker__group h3 {
margin: 0 0 8px;
color: #475569;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-align: center;
text-transform: uppercase;
}
.expression-picker__group--up-left {
grid-area: up-left;
}
.expression-picker__group--up-center {
grid-area: up-center;
}
.expression-picker__group--up-right {
grid-area: up-right;
}
.expression-picker__group--middle-left {
grid-area: middle-left;
}
.expression-picker__group--middle-center {
grid-area: middle-center;
padding: 16px;
border-radius: 24px;
background: white;
box-shadow: 0 8px 24px rgb(15 23 42 / 8%);
}
.expression-picker__group--middle-center .expression-picker__group-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.expression-picker__group--middle-right {
grid-area: middle-right;
}
.expression-picker__group--down-left {
grid-area: down-left;
}
.expression-picker__group--down-center {
grid-area: down-center;
}
.expression-picker__group--down-right {
grid-area: down-right;
}
.expression-picker__group-list {
display: grid;
gap: 8px;
}
.expression-picker__group button {
width: 100%;
min-height: 52px;
padding: 8px 12px;
border: 1px solid #cbd5e1;
border-radius: 12px;
color: #1e293b;
background: white;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25;
transition:
border-color 160ms ease,
background-color 160ms ease,
color 160ms ease,
transform 160ms ease;
}
.expression-picker__group button:hover {
border-color: #818cf8;
background: #eef2ff;
}
.expression-picker__group button:active {
transform: scale(0.98);
}
button {
min-height: 44px;
padding: 0 20px;
@ -38,7 +168,46 @@ button {
cursor: pointer;
}
button.is-selected {
border-color: #312e81;
background: #312e81;
color: white;
box-shadow: 0 0 0 3px #c7d2fe;
}
button:focus-visible {
outline: 3px solid #4338ca;
outline-offset: 3px;
}
@media (max-width: 640px) {
.expression-picker__heading {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.expression-picker__list {
grid-template-areas: none;
grid-template-columns: 1fr;
grid-template-rows: none;
aspect-ratio: auto;
min-height: 0;
gap: 20px;
padding: 0;
border: 0;
border-radius: 0;
background: none;
}
.expression-picker__group {
grid-area: auto;
}
.expression-picker__group--middle-center {
padding: 0;
border-radius: 0;
background: none;
box-shadow: none;
}
}

View File

@ -84,6 +84,30 @@ describe('@bible-strong/avatar-core', () => {
})
})
it('interpolates and completes a direct expression transition', () => {
const state = {
...createAvatarPlaybackState(),
activeExpression: 'curious-left',
status: 'playing' as const,
directTransition: {
from: 'neutral',
startedAt: 1_000,
durationMs: 400,
transition: 'smooth' as const,
},
}
const start = renderAvatarFrame(definition, state, 1_000, { random: () => 0.5 })
const midway = renderAvatarFrame(definition, state, 1_200, { random: () => 0.5 })
const end = renderAvatarFrame(definition, state, 1_400, { random: () => 0.5 })
expect(midway.geometry).not.toEqual(start.geometry)
expect(midway.geometry).not.toEqual(end.geometry)
expect(advanceAvatarPlayback(definition, state, 1_400, { random: () => 0.5 })).toMatchObject({
activeExpression: 'curious-left',
status: 'stopped',
})
})
it('generates the same geometry through the public definition adapter', () => {
const scene = renderAvatarDefinition(definition, 'curious-left')
const body = bodyFromDefinition(definition.body)

View File

@ -74,6 +74,12 @@ export type AvatarPlaybackState = {
pausedAt?: number
blinkDueAt?: number
blinkStartedAt?: number
directTransition?: {
from: ExpressionKey
startedAt: number
durationMs: number
transition: AvatarAnimationDefinition['steps'][number]['transition']
}
}
export type AvatarRuntimeEnvironment = {
@ -141,6 +147,13 @@ export const advanceAvatarPlayback = (
now: number,
environment: AvatarRuntimeEnvironment
): AvatarPlaybackState => {
if (state.directTransition) {
if (now < state.directTransition.startedAt + state.directTransition.durationMs) {
return { ...state }
}
const { directTransition: _directTransition, ...next } = state
return { ...next, status: 'stopped' }
}
if (state.status !== 'playing' || !state.activeAnimation) return { ...state }
const resolved = resolveAnimation(definition, state.activeAnimation)
if (!resolved.ok || !resolved.value.steps.length) return { ...createAvatarPlaybackState() }
@ -241,7 +254,21 @@ export const renderAvatarFrame = (
)
let expression = expressionFromDefinition(state.activeExpression, targetDefinition)
let blink = 1
if (state.activeAnimation) {
if (state.directTransition && !environment.reduceMotion) {
const fromDefinition = definition.expressions[state.directTransition.from]
if (fromDefinition) {
const from = expressionFromDefinition(state.directTransition.from, fromDefinition)
const progress = easing(
state.directTransition.transition,
(now - state.directTransition.startedAt) / Math.max(state.directTransition.durationMs, 1)
)
expression = interpolatePose(
poseFromExpression(from),
poseFromExpression(expression),
progress
).expression
}
} else if (state.activeAnimation) {
const resolved = resolveAnimation(definition, state.activeAnimation)
if (resolved.ok) {
const step = resolved.value.steps[state.stepIndex]

View File

@ -31,6 +31,7 @@ import { createPortal } from 'react-dom'
import './styles.css'
const validatedDefinitions = new WeakSet<object>()
const controlledExpressionTransitionMs = 420
const assertValidDefinition = (definition: AvatarDefinition) => {
if (validatedDefinitions.has(definition)) return
@ -107,7 +108,11 @@ const samePlayback = (left: CorePlaybackState, right: CorePlaybackState) =>
left.phaseStartedAt === right.phaseStartedAt &&
left.transitionFrom === right.transitionFrom &&
left.blinkDueAt === right.blinkDueAt &&
left.blinkStartedAt === right.blinkStartedAt
left.blinkStartedAt === right.blinkStartedAt &&
left.directTransition?.from === right.directTransition?.from &&
left.directTransition?.startedAt === right.directTransition?.startedAt &&
left.directTransition?.durationMs === right.directTransition?.durationMs &&
left.directTransition?.transition === right.directTransition?.transition
const samePosition = (left: AvatarPosition, right: AvatarPosition) =>
left.x === right.x && left.y === right.y
@ -332,7 +337,24 @@ export function Avatar({
if (expression !== undefined) {
const resolved = resolveExpression(definition, expression)
if (resolved.ok) {
setPlayback({ ...createAvatarPlaybackState(), activeExpression: expression })
const current = playbackRef.current
const next = {
...createAvatarPlaybackState(),
activeExpression: expression,
...(current.activeExpression === expression
? {}
: {
status: 'playing' as const,
directTransition: {
from: current.activeExpression,
startedAt: performance.now(),
durationMs: controlledExpressionTransitionMs,
transition: 'smooth' as const,
},
}),
}
playbackRef.current = next
setPlayback(next)
}
return
}
@ -470,6 +492,12 @@ export function Avatar({
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!draggable || event.button !== 0) return
if (
mode === 'floating' &&
!(event.target instanceof Element && event.target.closest('.bs-avatar__drag-grip'))
) {
return
}
event.currentTarget.setPointerCapture(event.pointerId)
drag.current = {
pointerId: event.pointerId,
@ -545,7 +573,9 @@ export function Avatar({
role={draggable ? 'group' : 'img'}
aria-label={ariaLabel}
aria-description={
draggable ? 'Use arrow keys or move controls to reposition the avatar.' : undefined
draggable
? 'Use the drag handle, arrow keys or move controls to reposition the avatar.'
: undefined
}
tabIndex={draggable ? 0 : undefined}
onPointerDown={startDrag}
@ -565,6 +595,7 @@ export function Avatar({
event.preventDefault()
}}
>
{draggable && <div className="bs-avatar__drag-grip" aria-hidden="true" title="Drag avatar" />}
<svg className="bs-avatar__svg" viewBox="-150 -150 300 300" aria-hidden="true">
<defs>
<clipPath id={clipId}>

View File

@ -137,6 +137,18 @@ describe('@bible-strong/avatar-react', () => {
})
})
it('smoothly transitions when a controlled expression changes', () => {
const controller = createRef<AvatarController>()
const view = render(<Avatar definition={definition} expression="neutral" ref={controller} />)
view.rerender(<Avatar definition={definition} expression="smile" ref={controller} />)
expect(controller.current?.getState()).toEqual({
activeExpression: 'smile',
status: 'playing',
})
})
it('rejects simultaneous controlled animation and expression props', () => {
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
expect(() =>
@ -226,6 +238,29 @@ describe('@bible-strong/avatar-react', () => {
expect(previews.length).toBeLessThanOrEqual(1)
})
it('uses the visible grip as the floating avatar drag target', () => {
const view = render(
<Avatar
definition={definition}
mode="floating"
draggable
constrainTo="none"
initialPosition={{ x: 0, y: 0 }}
/>
)
const avatar = view.getByRole('group')
const grip = avatar.querySelector('.bs-avatar__drag-grip')
expect(grip).not.toBeNull()
fireEvent.pointerDown(avatar, { pointerId: 8, button: 0, clientX: 0, clientY: 0 })
fireEvent.pointerMove(avatar, { pointerId: 8, clientX: 20, clientY: 20 })
expect(avatar.style.transform).toBe('translate3d(0px, 0px, 0)')
fireEvent.pointerDown(grip!, { pointerId: 9, button: 0, clientX: 0, clientY: 0 })
fireEvent.pointerMove(avatar, { pointerId: 9, clientX: 20, clientY: 20 })
expect(avatar.style.transform).toBe('translate3d(20px, 20px, 0)')
})
it('limits drag previews to one callback per animation frame with the latest position', () => {
let nextFrame = 0
const frames = new Map<number, FrameRequestCallback>()

View File

@ -27,6 +27,55 @@
cursor: grabbing;
}
.bs-avatar--floating.bs-avatar--draggable {
cursor: default;
}
.bs-avatar__drag-grip {
position: absolute;
z-index: 1;
top: 4px;
left: 50%;
width: 44px;
height: 28px;
border: 1px solid rgb(15 23 42 / 18%);
border-radius: 999px;
background: rgb(255 255 255 / 92%);
box-shadow: 0 2px 6px rgb(15 23 42 / 14%);
cursor: grab;
opacity: 0;
pointer-events: none;
touch-action: none;
transform: translateX(-50%) translateY(-4px);
transition:
opacity 160ms ease,
transform 160ms ease;
}
.bs-avatar__drag-grip::before {
position: absolute;
top: 8px;
left: 13px;
width: 18px;
height: 12px;
content: '';
background-image: radial-gradient(circle, #475569 1.5px, transparent 1.7px);
background-size: 6px 6px;
}
.bs-avatar--dragging .bs-avatar__drag-grip {
cursor: grabbing;
}
.bs-avatar:hover .bs-avatar__drag-grip,
.bs-avatar:focus-visible .bs-avatar__drag-grip,
.bs-avatar:focus-within .bs-avatar__drag-grip,
.bs-avatar--dragging .bs-avatar__drag-grip {
opacity: 1;
pointer-events: auto;
transform: translateX(-50%);
}
.bs-avatar:focus-visible {
outline: 3px solid currentColor;
outline-offset: 3px;
@ -89,6 +138,14 @@
opacity: 0.75;
}
@media (hover: none) {
.bs-avatar__drag-grip {
opacity: 1;
pointer-events: auto;
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.bs-avatar,
.bs-avatar * {