feat(studio): refine responsive workspace navigation

Add a resizable stacked layout and move avatar selection into a dedicated grid page.
This commit is contained in:
smontlouis 2026-08-13 22:47:52 +02:00
parent 9fcfec9076
commit 398c7f53e2
10 changed files with 208 additions and 91 deletions

View File

@ -41,6 +41,7 @@
"motion": "^13.0.0", "motion": "^13.0.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-resizable-panels": "^4.12.2",
"tailwind-merge": "^3.6.0" "tailwind-merge": "^3.6.0"
}, },
"devDependencies": { "devDependencies": {

14
pnpm-lock.yaml generated
View File

@ -35,6 +35,9 @@ importers:
react-dom: react-dom:
specifier: 19.2.3 specifier: 19.2.3
version: 19.2.3(react@19.2.3) version: 19.2.3(react@19.2.3)
react-resizable-panels:
specifier: ^4.12.2
version: 4.12.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
tailwind-merge: tailwind-merge:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
@ -879,6 +882,12 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.3 react: ^19.2.3
react-resizable-panels@4.12.2:
resolution: {integrity: sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react@19.2.3: react@19.2.3:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -1648,6 +1657,11 @@ snapshots:
react: 19.2.3 react: 19.2.3
scheduler: 0.27.0 scheduler: 0.27.0
react-resizable-panels@4.12.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react@19.2.3: {} react@19.2.3: {}
reselect@5.2.0: {} reselect@5.2.0: {}

View File

@ -12,7 +12,7 @@ import { type BodyNode } from '@/features/avatar/body'
import { poseFromExpression, renderAvatar, type Expression } from '@/features/avatar/geometry' import { poseFromExpression, renderAvatar, type Expression } from '@/features/avatar/geometry'
import { type SurfaceConfig } from '@/features/avatar/surfaces' import { type SurfaceConfig } from '@/features/avatar/surfaces'
export type Mode = 'manual' | 'expressions' | 'states' | 'export' export type Mode = 'avatars' | 'manual' | 'expressions' | 'states' | 'export'
export type ExportFormat = 'react' | 'javascript' export type ExportFormat = 'react' | 'javascript'
export type SnapshotFormat = 'svg' | 'png' export type SnapshotFormat = 'svg' | 'png'
export type PlaybackStatus = 'playing' | 'paused' | 'stopped' export type PlaybackStatus = 'playing' | 'paused' | 'stopped'

View File

@ -65,6 +65,9 @@ input {
min-height: 100vh; min-height: 100vh;
grid-template-columns: minmax(460px, 1fr) minmax(440px, 520px); grid-template-columns: minmax(460px, 1fr) minmax(440px, 520px);
} }
.studio-root {
min-height: 100vh;
}
.stage-column { .stage-column {
position: sticky; position: sticky;
@ -290,6 +293,7 @@ input {
position: relative; position: relative;
z-index: 1; z-index: 1;
width: min(72vh, 72vw, 680px); width: min(72vh, 72vw, 680px);
max-width: 100%;
aspect-ratio: 1; aspect-ratio: 1;
} }
.stage-playback-status { .stage-playback-status {
@ -1289,14 +1293,15 @@ p {
font-size: 8px; font-size: 8px;
} }
.avatar-shelf .avatar-grid { .avatar-shelf .avatar-grid {
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px; gap: 10px;
} }
.avatar-shelf .avatar-card, .avatar-shelf .avatar-card,
.avatar-shelf .avatar-add { .avatar-shelf .avatar-add {
height: 74px; height: auto;
min-height: 74px; min-height: 0;
border-radius: 10px; aspect-ratio: 1;
border-radius: 14px;
} }
.avatar-sort-item { .avatar-sort-item {
min-width: 0; min-width: 0;
@ -1318,13 +1323,14 @@ p {
cursor: grabbing; cursor: grabbing;
} }
.avatar-shelf .avatar-card svg { .avatar-shelf .avatar-card svg {
height: 48px; height: auto;
flex: 0 1 48px; min-height: 0;
flex: 1 1 auto;
} }
.avatar-shelf .avatar-card > span { .avatar-shelf .avatar-card > span {
min-height: 12px; min-height: 12px;
padding: 0 3px; padding: 2px 4px;
font-size: 8px; font-size: 9px;
} }
.tabs { .tabs {
position: sticky; position: sticky;
@ -2333,20 +2339,77 @@ p {
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.studio-root,
.studio { .studio {
grid-template-columns: 1fr; height: 100dvh;
min-height: 0;
overflow: hidden;
}
.studio-mobile-resizable {
display: flex;
width: 100%;
background: #f5f6f8;
}
.mobile-stage-panel,
.mobile-inspector-panel {
height: 100%;
min-height: 0;
overflow: hidden;
}
.studio-resize-handle {
z-index: 30;
height: 14px;
min-height: 14px;
border-block: 1px solid #d9dce3;
background: #f5f6f8;
touch-action: none;
}
.studio-resize-handle::after {
height: 40px;
}
.studio-resize-handle > div {
width: 38px;
height: 4px;
border-radius: 999px;
background: #b9bfca;
rotate: 0deg !important;
transform: none;
transition:
background-color 160ms ease,
width 160ms ease;
}
.studio-resize-handle:hover > div,
.studio-resize-handle:focus-visible > div,
.studio-resize-handle[data-resize-handle-active] > div {
width: 46px;
background: #5b7fe5;
} }
.stage-column { .stage-column {
position: relative; position: relative;
height: min(72vh, 680px); width: 100%;
min-height: 480px; height: 100%;
min-height: 0;
} }
.inspector { .inspector {
width: 100%;
height: 100%;
min-height: 0;
overscroll-behavior: contain;
border-top: 1px solid #d9dce3; border-top: 1px solid #d9dce3;
border-left: 0; border-left: 0;
} }
.avatar-wrap { .avatar-wrap {
width: min(62vh, 82vw); width: auto;
max-width: 82vw;
height: min(100%, 82vw, 620px);
max-height: 100%;
}
.workspace-footer,
.state-playback-footer,
.mobile-mode-tabs {
right: 0;
left: 0;
width: 100%;
} }
} }
@ -2358,17 +2421,7 @@ p {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
.studio {
height: 100dvh;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.stage-column { .stage-column {
position: relative;
aspect-ratio: 1;
height: auto;
min-height: 0;
padding: 10px; padding: 10px;
} }
.stage-identity { .stage-identity {
@ -2434,7 +2487,10 @@ p {
display: none; display: none;
} }
.avatar-wrap { .avatar-wrap {
width: min(42dvh, 78vw, 340px); width: auto;
max-width: 100%;
height: min(100%, 78vw, 340px);
max-height: 100%;
} }
.photo-capture-bar { .photo-capture-bar {
bottom: max(36px, calc(env(safe-area-inset-bottom) + 24px)); bottom: max(36px, calc(env(safe-area-inset-bottom) + 24px));
@ -2461,7 +2517,8 @@ p {
display: none; display: none;
} }
.inspector { .inspector {
height: auto; width: 100%;
height: 100%;
min-height: 0; min-height: 0;
overscroll-behavior: contain; overscroll-behavior: contain;
padding: 0 14px calc(var(--app-footer-clearance) + env(safe-area-inset-bottom)); padding: 0 14px calc(var(--app-footer-clearance) + env(safe-area-inset-bottom));
@ -2550,7 +2607,7 @@ p {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.avatar-shelf .avatar-grid { .avatar-shelf .avatar-grid {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
padding-right: 0; padding-right: 0;
} }
.avatar-edit { .avatar-edit {

View File

@ -0,0 +1,40 @@
import * as ResizablePrimitive from 'react-resizable-panels'
import { cn } from '@/lib/utils'
function ResizablePanelGroup({ className, ...props }: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn('flex h-full w-full aria-[orientation=vertical]:flex-col', className)}
{...props}
/>
)
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
'relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90',
className
)}
{...props}
>
{withHandle && <div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />}
</ResizablePrimitive.Separator>
)
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }

View File

@ -1,5 +1,5 @@
import { Copy, Pencil, Plus, Trash2 } from 'lucide-react' import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'
import { motion, useDragControls } from 'motion/react' import { motion } from 'motion/react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@ -9,20 +9,12 @@ import {
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
} from '@/components/ui/context-menu' } from '@/components/ui/context-menu'
import { DrawerContent, DrawerDescription, DrawerTitle } from '@/components/ui/drawer'
import { defaultAvatarEyes } from '@/features/avatar/avatars' import { defaultAvatarEyes } from '@/features/avatar/avatars'
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace' import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
import { defaultExpression } from '@/features/avatar/presets' import { defaultExpression } from '@/features/avatar/presets'
import type { StudioController } from '@/features/studio/useStudioController' import type { StudioController } from '@/features/studio/useStudioController'
export function AvatarDrawer({ export function AvatarPage({ controller }: { controller: StudioController }) {
controller,
onOpenChange,
}: {
controller: StudioController
onOpenChange: (open: boolean) => void
}) {
const drawerDragControls = useDragControls()
const { const {
activateAvatar, activateAvatar,
activeAvatarId, activeAvatarId,
@ -46,38 +38,12 @@ export function AvatarDrawer({
} = controller } = controller
return ( return (
<DrawerContent <div className="panel-stack avatar-page">
className="avatar-drawer" <section className="avatar-shelf" aria-label={t('Choisir un avatar')}>
render={ <div className="avatar-shelf-heading">
<motion.div <strong>{t('Double-clic pour modifier')}</strong>
drag="y"
dragControls={drawerDragControls}
dragListener={false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.7 }}
dragMomentum={false}
onDragEnd={(_, info) => {
if (info.offset.y > 70 || info.velocity.y > 600) onOpenChange(false)
}}
/>
}
>
<button
className="drawer-handle"
type="button"
aria-label="Close avatar picker"
onPointerDown={event => drawerDragControls.start(event)}
>
<span />
</button>
<div className="avatar-drawer-heading">
<div>
<DrawerTitle>Avatars</DrawerTitle>
<DrawerDescription>{t('Double-clic pour modifier')}</DrawerDescription>
</div>
<span>{avatars.length}</span> <span>{avatars.length}</span>
</div> </div>
<section className="avatar-shelf" aria-label={t('Choisir un avatar')}>
<div className="avatar-grid"> <div className="avatar-grid">
{avatars.map(avatar => ( {avatars.map(avatar => (
<motion.div <motion.div
@ -125,7 +91,6 @@ export function AvatarDrawer({
onDoubleClick={() => { onDoubleClick={() => {
setFocusAvatarName(false) setFocusAvatarName(false)
activateAvatar(avatar.id, true) activateAvatar(avatar.id, true)
onOpenChange(false)
}} }}
> >
<ExpressionPreview <ExpressionPreview
@ -145,7 +110,6 @@ export function AvatarDrawer({
onClick={() => { onClick={() => {
setFocusAvatarName(false) setFocusAvatarName(false)
activateAvatar(avatar.id, true) activateAvatar(avatar.id, true)
onOpenChange(false)
}} }}
> >
<Pencil /> {t('Modifier')} <Pencil /> {t('Modifier')}
@ -159,7 +123,6 @@ export function AvatarDrawer({
disabled={avatars.length <= 1} disabled={avatars.length <= 1}
onClick={() => { onClick={() => {
activateAvatar(avatar.id, false, true) activateAvatar(avatar.id, false, true)
onOpenChange(false)
setDeleteAvatarOpen(true) setDeleteAvatarOpen(true)
}} }}
> >
@ -172,16 +135,13 @@ export function AvatarDrawer({
<Button <Button
variant="outline" variant="outline"
className="avatar-add creation-card" className="avatar-add creation-card"
onClick={() => { onClick={createNewAvatar}
createNewAvatar()
onOpenChange(false)
}}
aria-label={t('Nouvel avatar')} aria-label={t('Nouvel avatar')}
> >
<Plus /> <Plus />
</Button> </Button>
</div> </div>
</section> </section>
</DrawerContent> </div>
) )
} }

View File

@ -63,13 +63,11 @@ import {
import { defaultExpression } from '@/features/avatar/presets' import { defaultExpression } from '@/features/avatar/presets'
import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces' import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces'
import { type SnapshotBackground } from '@/features/export/snapshotExporter' import { type SnapshotBackground } from '@/features/export/snapshotExporter'
import { AvatarDrawer } from '@/features/studio/components/AvatarDrawer' import { AvatarPage } from '@/features/studio/components/AvatarDrawer'
import { StudioIdentity } from '@/features/studio/components/StudioIdentity' import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
import type { StudioController } from '@/features/studio/useStudioController' import type { StudioController } from '@/features/studio/useStudioController'
import { useState } from 'react'
export function StudioInspector({ controller }: { controller: StudioController }) { export function StudioInspector({ controller }: { controller: StudioController }) {
const [avatarDrawerOpen, setAvatarDrawerOpen] = useState(false)
const { const {
activateAvatar, activateAvatar,
activeAvatar, activeAvatar,
@ -208,7 +206,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
workspaceBackButtonRef, workspaceBackButtonRef,
} = controller } = controller
return ( return (
<Drawer open={avatarDrawerOpen} onOpenChange={setAvatarDrawerOpen}> <Drawer>
<main <main
className={`inspector ${editing ? 'expression-workspace-active' : sequenceEditing ? 'sequence-workspace-active' : bodyEditing ? 'body-workspace' : 'studio-workspace'}${activeSequence && !editorPageOpen ? ' state-player-active' : ''}`} className={`inspector ${editing ? 'expression-workspace-active' : sequenceEditing ? 'sequence-workspace-active' : bodyEditing ? 'body-workspace' : 'studio-workspace'}${activeSequence && !editorPageOpen ? ' state-player-active' : ''}`}
> >
@ -330,6 +328,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
{t( {t(
mode === 'manual' mode === 'manual'
? 'Pose' ? 'Pose'
: mode === 'avatars'
? 'Avatars'
: mode === 'expressions' : mode === 'expressions'
? 'Expressions' ? 'Expressions'
: mode === 'states' : mode === 'states'
@ -1229,6 +1229,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
)} )}
</div> </div>
)} )}
{!editorPageOpen && mode === 'avatars' && <AvatarPage controller={controller} />}
{!sequenceEditing && !editing && bodyEditing && ( {!sequenceEditing && !editing && bodyEditing && (
<footer className="workspace-footer"> <footer className="workspace-footer">
@ -1921,9 +1922,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
className="mobile-mode-tab mobile-avatar-tab" className="mobile-mode-tab mobile-avatar-tab"
variant="ghost" variant="ghost"
type="button" type="button"
aria-pressed={avatarDrawerOpen} aria-pressed={mode === 'avatars'}
aria-label={t('Choisir un avatar')} aria-label={t('Choisir un avatar')}
onClick={() => setAvatarDrawerOpen(true)} onClick={() => setMode('avatars')}
> >
<ExpressionPreview <ExpressionPreview
expression={expressions[0] ?? defaultExpression} expression={expressions[0] ?? defaultExpression}
@ -1957,7 +1958,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
))} ))}
</nav> </nav>
)} )}
<AvatarDrawer controller={controller} onOpenChange={setAvatarDrawerOpen} />
</main> </main>
</Drawer> </Drawer>
) )

View File

@ -1,13 +1,56 @@
import { useEffect, useState } from 'react'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import { StudioDialogs } from '@/features/studio/components/StudioDialogs' import { StudioDialogs } from '@/features/studio/components/StudioDialogs'
import { StudioInspector } from '@/features/studio/components/StudioInspector' import { StudioInspector } from '@/features/studio/components/StudioInspector'
import { StudioStage } from '@/features/studio/components/StudioStage' import { StudioStage } from '@/features/studio/components/StudioStage'
import type { StudioController } from '@/features/studio/useStudioController' import type { StudioController } from '@/features/studio/useStudioController'
const STACKED_LAYOUT_QUERY = '(max-width: 980px)'
export function StudioView(controller: StudioController) { export function StudioView(controller: StudioController) {
const [stackedLayout, setStackedLayout] = useState(
() => window.matchMedia(STACKED_LAYOUT_QUERY).matches
)
useEffect(() => {
const mediaQuery = window.matchMedia(STACKED_LAYOUT_QUERY)
const updateStackedLayout = () => setStackedLayout(mediaQuery.matches)
mediaQuery.addEventListener('change', updateStackedLayout)
return () => mediaQuery.removeEventListener('change', updateStackedLayout)
}, [])
return ( return (
<div className="studio" lang={controller.language}> <div className="studio-root" lang={controller.language}>
{stackedLayout ? (
<ResizablePanelGroup
className="studio studio-mobile-resizable"
orientation="vertical"
resizeTargetMinimumSize={{ coarse: 40, fine: 12 }}
>
<ResizablePanel
id="preview"
className="mobile-stage-panel"
defaultSize="42%"
minSize={180}
>
<StudioStage controller={controller} />
</ResizablePanel>
<ResizableHandle
className="studio-resize-handle"
withHandle
aria-label={controller.t('Redimensionner l’aperçu et l’éditeur')}
/>
<ResizablePanel id="editor" className="mobile-inspector-panel" minSize={260}>
<StudioInspector controller={controller} />
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="studio">
<StudioStage controller={controller} /> <StudioStage controller={controller} />
<StudioInspector controller={controller} /> <StudioInspector controller={controller} />
</div>
)}
<StudioDialogs controller={controller} /> <StudioDialogs controller={controller} />
</div> </div>
) )

View File

@ -41,6 +41,7 @@ const english: Record<string, string> = {
'Ouvrez cette démo directement, ou servez le dossier localement pour importer avatar.js depuis un autre module.': 'Ouvrez cette démo directement, ou servez le dossier localement pour importer avatar.js depuis un autre module.':
'Open this demo directly, or serve the folder locally to import avatar.js from another module.', 'Open this demo directly, or serve the folder locally to import avatar.js from another module.',
'Langue de l’interface': 'Interface language', 'Langue de l’interface': 'Interface language',
'Redimensionner l’aperçu et l’éditeur': 'Resize preview and editor',
'Nom de l’avatar': 'Avatar name', 'Nom de l’avatar': 'Avatar name',
sélecteur: 'picker', sélecteur: 'picker',
hexadécimal: 'hex value', hexadécimal: 'hex value',

View File

@ -14,6 +14,7 @@ export const chinese: Record<string, string> = {
'Ouvrez cette démo directement, ou servez le dossier localement pour importer avatar.js depuis un autre module.': 'Ouvrez cette démo directement, ou servez le dossier localement pour importer avatar.js depuis un autre module.':
'可直接打开此演示,或在本地启动该目录,以便从其他模块导入 avatar.js。', '可直接打开此演示,或在本地启动该目录,以便从其他模块导入 avatar.js。',
'Langue de l’interface': '界面语言', 'Langue de l’interface': '界面语言',
'Redimensionner l’aperçu et l’éditeur': '调整预览和编辑器大小',
'Nom de l’avatar': '头像名称', 'Nom de l’avatar': '头像名称',
sélecteur: '选择器', sélecteur: '选择器',
hexadécimal: '十六进制值', hexadécimal: '十六进制值',