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:
parent
398c7f53e2
commit
af3107ce20
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,6 +381,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
>
|
||||
{!editorPageOpen && (
|
||||
<header className="mode-page-header">
|
||||
<div>
|
||||
<p className="eyebrow">{activeAvatar.name}</p>
|
||||
<h1>
|
||||
{t(
|
||||
@ -337,6 +396,18 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
: '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"
|
||||
<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'
|
||||
)}
|
||||
onClick={() => setStatePlayerExpanded(expanded => !expanded)}
|
||||
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)
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
<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 && (
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user