From af3107ce208ef0a3585fdc7701c1ebbec48cf564 Mon Sep 17 00:00:00 2001 From: smontlouis Date: Thu, 13 Aug 2026 23:29:49 +0200 Subject: [PATCH] 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. --- src/app/styles.css | 73 ++++- .../avatar/components/ExpressionWorkspace.tsx | 12 + .../studio/components/StudioInspector.tsx | 280 ++++++++++++------ src/features/studio/useStudioController.ts | 4 +- 4 files changed, 258 insertions(+), 111 deletions(-) diff --git a/src/app/styles.css b/src/app/styles.css index 75b5c34..0e393c1 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -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; diff --git a/src/features/avatar/components/ExpressionWorkspace.tsx b/src/features/avatar/components/ExpressionWorkspace.tsx index b61b80b..808dbdb 100644 --- a/src/features/avatar/components/ExpressionWorkspace.tsx +++ b/src/features/avatar/components/ExpressionWorkspace.tsx @@ -247,6 +247,18 @@ export function ExpressionWorkspace({

{t('L’avatar à gauche affiche cette expression en direct.')}

+ {editing.index === null && ( + + )}
diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx index 6a371d5..fe17652 100644 --- a/src/features/studio/components/StudioInspector.tsx +++ b/src/features/studio/components/StudioInspector.tsx @@ -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(null) + const measuredSequenceIdRef = useRef(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 (
{!editorPageOpen && (
-

{activeAvatar.name}

-

- {t( - mode === 'manual' - ? 'Pose' - : mode === 'avatars' - ? 'Avatars' - : mode === 'expressions' - ? 'Expressions' - : mode === 'states' - ? 'Animations' - : 'Exporter' - )} -

+
+

{activeAvatar.name}

+

+ {t( + mode === 'manual' + ? 'Pose' + : mode === 'avatars' + ? 'Avatars' + : mode === 'expressions' + ? 'Expressions' + : mode === 'states' + ? 'Animations' + : 'Exporter' + )} +

+
+ {mode === 'manual' && ( + + )}
)} @@ -1215,14 +1286,6 @@ export function StudioInspector({ controller }: { controller: StudioController } aria-label={t('Afficher le maillage')} />
- @@ -1792,79 +1855,46 @@ export function StudioInspector({ controller }: { controller: StudioController } {activeSequence && !editorPageOpen && ( - - {statePlayerExpanded && ( - -
-
-

{t('Détails de l’animation')}

-

{activeSequenceLabel}

-

- {activeSequence.builtIn - ? t(activeSequence.description) - : activeSequence.description} -

-
- - {t('Mode de lecture')} · {t(activeSequence.playbackMode)} - -
-
-
- {t('Expressions')} - {activeSequence.steps.length} - - {activeSequence.steps - .map(step => formatSeconds(step.holdMs, language)) - .join(' · ')} - -
-
- {t('Premier clignement')} - - {activeSequence.blink.enabled - ? formatSeconds(activeSequence.blink.initialDelayMs, language) - : t('Désactivé')} - - {t('après le lancement')} -
-
- {t('Intervalle du clignement')} - - {formatSeconds(activeSequence.blink.minIntervalMs, language)}– - {formatSeconds(activeSequence.blink.maxIntervalMs, language)} - - {t('tirage aléatoire')} -
-
- {t('Durée du clignement')} - {activeSequence.blink.durationMs} ms - {t('fermeture et ouverture')} -
-
+
+ + 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) + }} + > + + - )} +
{activeSequence.steps.map((step, position) => { @@ -1914,6 +1944,62 @@ export function StudioInspector({ controller }: { controller: StudioController } />
+ +
+
+
+

{t('Détails de l’animation')}

+

{activeSequenceLabel}

+

+ {activeSequence.builtIn + ? t(activeSequence.description) + : activeSequence.description} +

+
+ + {t('Mode de lecture')} · {t(activeSequence.playbackMode)} + +
+
+
+ {t('Expressions')} + {activeSequence.steps.length} + + {activeSequence.steps + .map(step => formatSeconds(step.holdMs, language)) + .join(' · ')} + +
+
+ {t('Premier clignement')} + + {activeSequence.blink.enabled + ? formatSeconds(activeSequence.blink.initialDelayMs, language) + : t('Désactivé')} + + {t('après le lancement')} +
+
+ {t('Intervalle du clignement')} + + {formatSeconds(activeSequence.blink.minIntervalMs, language)}– + {formatSeconds(activeSequence.blink.maxIntervalMs, language)} + + {t('tirage aléatoire')} +
+
+ {t('Durée du clignement')} + {activeSequence.blink.durationMs} ms + {t('fermeture et ouverture')} +
+
+
+
)} {!editorPageOpen && ( diff --git a/src/features/studio/useStudioController.ts b/src/features/studio/useStudioController.ts index 9e50dee..21551b2 100644 --- a/src/features/studio/useStudioController.ts +++ b/src/features/studio/useStudioController.ts @@ -113,7 +113,7 @@ import { export function useStudioController() { const { language, setLanguage, t } = useStudioLanguage() - const [mode, setMode] = useState('manual') + const [mode, setMode] = useState('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() }