feat(studio): polish editing and playback controls

Default to the avatar library, add contextual reset actions, and turn animation playback details into a draggable footer.
This commit is contained in:
smontlouis 2026-08-13 23:29:49 +02:00
parent 398c7f53e2
commit af3107ce20
4 changed files with 258 additions and 111 deletions

View File

@ -67,6 +67,7 @@ input {
}
.studio-root {
min-height: 100vh;
overflow-x: clip;
}
.stage-column {
@ -932,6 +933,10 @@ input {
font-size: 11px;
line-height: 1.45;
}
.workspace-header-reset {
flex: 0 0 auto;
margin-top: 18px;
}
.workspace-header-actions {
display: flex;
flex: 0 0 auto;
@ -975,6 +980,10 @@ input {
margin-bottom: 22px;
}
.mode-page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
padding: 4px 2px 0;
}
@ -1072,20 +1081,57 @@ p {
bottom: 62px;
width: min(520px, 100vw);
box-sizing: border-box;
padding: 10px 18px;
padding: 24px 18px 10px;
border-top: 1px solid #d9dce3;
background: rgb(250 251 253 / 96%);
box-shadow: 0 -10px 30px rgb(24 28 35 / 8%);
backdrop-filter: blur(14px);
}
.state-playback-expand {
.state-playback-drag-handle-slot {
position: absolute;
z-index: 2;
top: 0;
left: 50%;
right: 0;
left: 0;
display: flex;
height: 24px;
align-items: center;
justify-content: center;
pointer-events: none;
}
.state-playback-drag-handle {
display: grid;
width: 64px;
height: 24px;
padding: 0;
border: 0;
outline: 0;
border-radius: 999px;
background: white;
box-shadow: 0 4px 14px rgb(24 28 35 / 9%);
transform: translate(-50%, -50%);
background: transparent;
cursor: grab;
place-items: center;
pointer-events: auto;
touch-action: none;
}
.state-playback-drag-handle:active {
cursor: grabbing;
}
.state-playback-drag-handle:focus-visible {
box-shadow: 0 0 0 3px rgb(91 127 229 / 28%);
}
.state-playback-drag-handle > span {
width: 38px;
height: 4px;
border-radius: 999px;
background: #b9bfca;
transition:
width 160ms ease,
background-color 160ms ease;
}
.state-playback-drag-handle:hover > span,
.state-playback-drag-handle:focus-visible > span {
width: 44px;
background: #5b7fe5;
}
.state-playback-bar {
display: flex;
@ -1093,11 +1139,15 @@ p {
align-items: center;
gap: 14px;
}
.state-playback-details-shell {
will-change: opacity;
}
.state-playback-details {
display: grid;
overflow: hidden;
gap: 14px;
padding: 14px 2px 16px;
border-bottom: 1px solid #dfe3eb;
padding: 16px 2px 6px;
border-top: 1px solid #dfe3eb;
}
.state-playback-details-header {
display: flex;
@ -1151,9 +1201,6 @@ p {
.state-playback-detail-grid strong {
font-size: 12px;
}
.state-playback-footer.is-expanded .state-playback-bar {
padding-top: 10px;
}
.state-playback-controls {
display: flex;
flex: 0 0 auto;
@ -1173,7 +1220,7 @@ p {
min-width: 0;
flex: 1;
gap: 6px;
overflow-x: auto;
overflow-x: scroll;
padding: 5px 3px 7px;
scroll-behavior: smooth;
scrollbar-color: #cbd2df transparent;
@ -2624,7 +2671,7 @@ p {
}
.state-playback-footer {
bottom: calc(62px + env(safe-area-inset-bottom));
padding: 6px 10px;
padding: 22px 10px 6px;
}
.state-playback-bar {
gap: 8px;

View File

@ -247,6 +247,18 @@ export function ExpressionWorkspace({
</h1>
<p>{t('L’avatar à gauche affiche cette expression en direct.')}</p>
</div>
{editing.index === null && (
<Button
className="workspace-header-reset"
variant="outline"
size="icon"
type="button"
aria-label={t('Réinitialiser')}
onClick={() => onChange({ ...defaultExpression })}
>
<RotateCcw />
</Button>
)}
</header>
<div className="workspace-scroll">
<div className="dialog-fields">

View File

@ -1,7 +1,5 @@
import {
ArrowLeft,
ChevronDown,
ChevronUp,
Copy,
Download,
FileCode2,
@ -14,8 +12,8 @@ import {
Trash2,
Upload,
} from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { type CSSProperties } from 'react'
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react'
import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react'
import { Accordion } from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
@ -205,6 +203,66 @@ export function StudioInspector({ controller }: { controller: StudioController }
updateWireVisibility,
workspaceBackButtonRef,
} = controller
const playbackFooterY = useMotionValue(0)
const playbackHandleY = useMotionValue(0)
const playbackHandleCounterY = useTransform(playbackHandleY, value => -value)
const playbackFooterDragOriginY = useRef(0)
const playbackDetailsRef = useRef<HTMLDivElement>(null)
const measuredSequenceIdRef = useRef<string | null>(null)
const [playbackFooterCollapsedOffset, setPlaybackFooterCollapsedOffset] = useState(0)
const playbackDetailsOpacity = useTransform(
playbackFooterY,
[0, Math.max(playbackFooterCollapsedOffset, 1)],
[1, 0]
)
useLayoutEffect(() => {
const details = playbackDetailsRef.current
if (!details || !activeSequence) return
const measure = () => {
const nextOffset = details.getBoundingClientRect().height
if (nextOffset <= 0) return
const firstMeasurement = measuredSequenceIdRef.current !== activeSequence.id
if (firstMeasurement) {
measuredSequenceIdRef.current = activeSequence.id
playbackFooterY.set(statePlayerExpanded ? 0 : nextOffset)
} else if (
playbackFooterCollapsedOffset > 0 &&
Math.abs(nextOffset - playbackFooterCollapsedOffset) > 0.5
) {
const progress = Math.min(
1,
Math.max(0, playbackFooterY.get() / playbackFooterCollapsedOffset)
)
playbackFooterY.set(progress * nextOffset)
}
setPlaybackFooterCollapsedOffset(nextOffset)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(details)
return () => observer.disconnect()
}, [
activeSequence,
language,
playbackFooterCollapsedOffset,
playbackFooterY,
statePlayerExpanded,
])
const snapPlaybackFooter = (expanded: boolean) => {
setStatePlayerExpanded(expanded)
animate(playbackFooterY, expanded ? 0 : playbackFooterCollapsedOffset, {
type: 'spring',
stiffness: 440,
damping: 40,
duration: reduceMotion ? 0 : undefined,
})
}
return (
<Drawer>
<main
@ -323,20 +381,33 @@ export function StudioInspector({ controller }: { controller: StudioController }
>
{!editorPageOpen && (
<header className="mode-page-header">
<p className="eyebrow">{activeAvatar.name}</p>
<h1>
{t(
mode === 'manual'
? 'Pose'
: mode === 'avatars'
? 'Avatars'
: mode === 'expressions'
? 'Expressions'
: mode === 'states'
? 'Animations'
: 'Exporter'
)}
</h1>
<div>
<p className="eyebrow">{activeAvatar.name}</p>
<h1>
{t(
mode === 'manual'
? 'Pose'
: mode === 'avatars'
? 'Avatars'
: mode === 'expressions'
? 'Expressions'
: mode === 'states'
? 'Animations'
: 'Exporter'
)}
</h1>
</div>
{mode === 'manual' && (
<Button
variant="outline"
size="icon"
type="button"
aria-label={t('Réinitialiser')}
onClick={() => transitionToExpression({ ...defaultExpression })}
>
<RotateCcw />
</Button>
)}
</header>
)}
@ -1215,14 +1286,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
aria-label={t('Afficher le maillage')}
/>
</div>
<Button
className="reset"
variant="outline"
type="button"
onClick={() => transitionToExpression({ ...defaultExpression })}
>
{t('Réinitialiser')}
</Button>
</InspectorCard>
</ControlSection>
</>
@ -1792,79 +1855,46 @@ export function StudioInspector({ controller }: { controller: StudioController }
{activeSequence && !editorPageOpen && (
<motion.footer
className={`state-playback-footer${statePlayerExpanded ? ' is-expanded' : ''}`}
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
style={{ y: playbackFooterY }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<Button
className="state-playback-expand"
variant="outline"
size="icon-sm"
type="button"
aria-expanded={statePlayerExpanded}
aria-label={t(
statePlayerExpanded
? 'Masquer les détails de l’animation'
: 'Afficher les détails de l’animation'
)}
onClick={() => setStatePlayerExpanded(expanded => !expanded)}
>
{statePlayerExpanded ? <ChevronDown /> : <ChevronUp />}
</Button>
{statePlayerExpanded && (
<motion.div
className="state-playback-details"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="state-playback-details-header">
<div>
<p className="eyebrow">{t('Détails de l’animation')}</p>
<h2>{activeSequenceLabel}</h2>
<p>
{activeSequence.builtIn
? t(activeSequence.description)
: activeSequence.description}
</p>
</div>
<Badge variant="secondary">
{t('Mode de lecture')} · {t(activeSequence.playbackMode)}
</Badge>
</div>
<div className="state-playback-detail-grid">
<div>
<span>{t('Expressions')}</span>
<strong>{activeSequence.steps.length}</strong>
<small>
{activeSequence.steps
.map(step => formatSeconds(step.holdMs, language))
.join(' · ')}
</small>
</div>
<div>
<span>{t('Premier clignement')}</span>
<strong>
{activeSequence.blink.enabled
? formatSeconds(activeSequence.blink.initialDelayMs, language)
: t('Désactivé')}
</strong>
<small>{t('après le lancement')}</small>
</div>
<div>
<span>{t('Intervalle du clignement')}</span>
<strong>
{formatSeconds(activeSequence.blink.minIntervalMs, language)}–
{formatSeconds(activeSequence.blink.maxIntervalMs, language)}
</strong>
<small>{t('tirage aléatoire')}</small>
</div>
<div>
<span>{t('Durée du clignement')}</span>
<strong>{activeSequence.blink.durationMs} ms</strong>
<small>{t('fermeture et ouverture')}</small>
</div>
</div>
<div className="state-playback-drag-handle-slot">
<motion.div style={{ y: playbackHandleCounterY }}>
<motion.button
className="state-playback-drag-handle"
style={{ y: playbackHandleY }}
type="button"
drag="y"
dragMomentum={false}
aria-expanded={statePlayerExpanded}
aria-label={t(
statePlayerExpanded
? 'Masquer les détails de l’animation'
: 'Afficher les détails de l’animation'
)}
onTap={() => snapPlaybackFooter(!statePlayerExpanded)}
onDragStart={() => {
playbackFooterDragOriginY.current = playbackFooterY.get()
}}
onDrag={(_, info) => {
playbackFooterY.set(
Math.min(
playbackFooterCollapsedOffset,
Math.max(0, playbackFooterDragOriginY.current + info.offset.y)
)
)
}}
onDragEnd={(_, info) => {
playbackHandleY.set(0)
const projectedY = playbackFooterY.get() + info.velocity.y * 0.16
snapPlaybackFooter(projectedY < playbackFooterCollapsedOffset / 2)
}}
>
<span />
</motion.button>
</motion.div>
)}
</div>
<div className="state-playback-bar">
<div className="state-playback-timeline">
{activeSequence.steps.map((step, position) => {
@ -1914,6 +1944,62 @@ export function StudioInspector({ controller }: { controller: StudioController }
/>
</div>
</div>
<motion.div
ref={playbackDetailsRef}
className="state-playback-details-shell"
style={{ opacity: playbackDetailsOpacity }}
aria-hidden={!statePlayerExpanded}
>
<div className="state-playback-details">
<div className="state-playback-details-header">
<div>
<p className="eyebrow">{t('Détails de l’animation')}</p>
<h2>{activeSequenceLabel}</h2>
<p>
{activeSequence.builtIn
? t(activeSequence.description)
: activeSequence.description}
</p>
</div>
<Badge variant="secondary">
{t('Mode de lecture')} · {t(activeSequence.playbackMode)}
</Badge>
</div>
<div className="state-playback-detail-grid">
<div>
<span>{t('Expressions')}</span>
<strong>{activeSequence.steps.length}</strong>
<small>
{activeSequence.steps
.map(step => formatSeconds(step.holdMs, language))
.join(' · ')}
</small>
</div>
<div>
<span>{t('Premier clignement')}</span>
<strong>
{activeSequence.blink.enabled
? formatSeconds(activeSequence.blink.initialDelayMs, language)
: t('Désactivé')}
</strong>
<small>{t('après le lancement')}</small>
</div>
<div>
<span>{t('Intervalle du clignement')}</span>
<strong>
{formatSeconds(activeSequence.blink.minIntervalMs, language)}–
{formatSeconds(activeSequence.blink.maxIntervalMs, language)}
</strong>
<small>{t('tirage aléatoire')}</small>
</div>
<div>
<span>{t('Durée du clignement')}</span>
<strong>{activeSequence.blink.durationMs} ms</strong>
<small>{t('fermeture et ouverture')}</small>
</div>
</div>
</div>
</motion.div>
</motion.footer>
)}
{!editorPageOpen && (

View File

@ -113,7 +113,7 @@ import {
export function useStudioController() {
const { language, setLanguage, t } = useStudioLanguage()
const [mode, setMode] = useState<Mode>('manual')
const [mode, setMode] = useState<Mode>('avatars')
const [initialDocument] = useState(loadStudioDocument)
const [documentStore] = useState(() => createStudioDocumentStore(initialDocument))
const initialLibrary = initialDocument.library
@ -830,6 +830,7 @@ export function useStudioController() {
const snapshot = avatarEditSnapshot.current
if (!snapshot) {
setBodyEditing(false)
setMode('avatars')
restoreStateAfterEditor()
return
}
@ -837,6 +838,7 @@ export function useStudioController() {
avatarsRef.current = snapshot.avatars
setAvatars(snapshot.avatars)
activateAvatar(snapshot.activeAvatarId, false, true)
setMode('avatars')
restoreStateAfterEditor()
}