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",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-resizable-panels": "^4.12.2",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {

14
pnpm-lock.yaml generated
View File

@ -35,6 +35,9 @@ importers:
react-dom:
specifier: 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:
specifier: ^3.6.0
version: 3.6.0
@ -879,6 +882,12 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
@ -1648,6 +1657,11 @@ snapshots:
react: 19.2.3
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: {}
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 { 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 SnapshotFormat = 'svg' | 'png'
export type PlaybackStatus = 'playing' | 'paused' | 'stopped'

View File

@ -65,6 +65,9 @@ input {
min-height: 100vh;
grid-template-columns: minmax(460px, 1fr) minmax(440px, 520px);
}
.studio-root {
min-height: 100vh;
}
.stage-column {
position: sticky;
@ -290,6 +293,7 @@ input {
position: relative;
z-index: 1;
width: min(72vh, 72vw, 680px);
max-width: 100%;
aspect-ratio: 1;
}
.stage-playback-status {
@ -1289,14 +1293,15 @@ p {
font-size: 8px;
}
.avatar-shelf .avatar-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.avatar-shelf .avatar-card,
.avatar-shelf .avatar-add {
height: 74px;
min-height: 74px;
border-radius: 10px;
height: auto;
min-height: 0;
aspect-ratio: 1;
border-radius: 14px;
}
.avatar-sort-item {
min-width: 0;
@ -1318,13 +1323,14 @@ p {
cursor: grabbing;
}
.avatar-shelf .avatar-card svg {
height: 48px;
flex: 0 1 48px;
height: auto;
min-height: 0;
flex: 1 1 auto;
}
.avatar-shelf .avatar-card > span {
min-height: 12px;
padding: 0 3px;
font-size: 8px;
padding: 2px 4px;
font-size: 9px;
}
.tabs {
position: sticky;
@ -2333,20 +2339,77 @@ p {
}
@media (max-width: 980px) {
.studio-root,
.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 {
position: relative;
height: min(72vh, 680px);
min-height: 480px;
width: 100%;
height: 100%;
min-height: 0;
}
.inspector {
width: 100%;
height: 100%;
min-height: 0;
overscroll-behavior: contain;
border-top: 1px solid #d9dce3;
border-left: 0;
}
.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%;
overflow: hidden;
}
.studio {
height: 100dvh;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.stage-column {
position: relative;
aspect-ratio: 1;
height: auto;
min-height: 0;
padding: 10px;
}
.stage-identity {
@ -2434,7 +2487,10 @@ p {
display: none;
}
.avatar-wrap {
width: min(42dvh, 78vw, 340px);
width: auto;
max-width: 100%;
height: min(100%, 78vw, 340px);
max-height: 100%;
}
.photo-capture-bar {
bottom: max(36px, calc(env(safe-area-inset-bottom) + 24px));
@ -2461,7 +2517,8 @@ p {
display: none;
}
.inspector {
height: auto;
width: 100%;
height: 100%;
min-height: 0;
overscroll-behavior: contain;
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));
}
.avatar-shelf .avatar-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
padding-right: 0;
}
.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 { motion, useDragControls } from 'motion/react'
import { motion } from 'motion/react'
import { Button } from '@/components/ui/button'
import {
@ -9,20 +9,12 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { DrawerContent, DrawerDescription, DrawerTitle } from '@/components/ui/drawer'
import { defaultAvatarEyes } from '@/features/avatar/avatars'
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
import { defaultExpression } from '@/features/avatar/presets'
import type { StudioController } from '@/features/studio/useStudioController'
export function AvatarDrawer({
controller,
onOpenChange,
}: {
controller: StudioController
onOpenChange: (open: boolean) => void
}) {
const drawerDragControls = useDragControls()
export function AvatarPage({ controller }: { controller: StudioController }) {
const {
activateAvatar,
activeAvatarId,
@ -46,38 +38,12 @@ export function AvatarDrawer({
} = controller
return (
<DrawerContent
className="avatar-drawer"
render={
<motion.div
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>
</div>
<div className="panel-stack avatar-page">
<section className="avatar-shelf" aria-label={t('Choisir un avatar')}>
<div className="avatar-shelf-heading">
<strong>{t('Double-clic pour modifier')}</strong>
<span>{avatars.length}</span>
</div>
<div className="avatar-grid">
{avatars.map(avatar => (
<motion.div
@ -125,7 +91,6 @@ export function AvatarDrawer({
onDoubleClick={() => {
setFocusAvatarName(false)
activateAvatar(avatar.id, true)
onOpenChange(false)
}}
>
<ExpressionPreview
@ -145,7 +110,6 @@ export function AvatarDrawer({
onClick={() => {
setFocusAvatarName(false)
activateAvatar(avatar.id, true)
onOpenChange(false)
}}
>
<Pencil /> {t('Modifier')}
@ -159,7 +123,6 @@ export function AvatarDrawer({
disabled={avatars.length <= 1}
onClick={() => {
activateAvatar(avatar.id, false, true)
onOpenChange(false)
setDeleteAvatarOpen(true)
}}
>
@ -172,16 +135,13 @@ export function AvatarDrawer({
<Button
variant="outline"
className="avatar-add creation-card"
onClick={() => {
createNewAvatar()
onOpenChange(false)
}}
onClick={createNewAvatar}
aria-label={t('Nouvel avatar')}
>
<Plus />
</Button>
</div>
</section>
</DrawerContent>
</div>
)
}

View File

@ -63,13 +63,11 @@ import {
import { defaultExpression } from '@/features/avatar/presets'
import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces'
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 type { StudioController } from '@/features/studio/useStudioController'
import { useState } from 'react'
export function StudioInspector({ controller }: { controller: StudioController }) {
const [avatarDrawerOpen, setAvatarDrawerOpen] = useState(false)
const {
activateAvatar,
activeAvatar,
@ -208,7 +206,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
workspaceBackButtonRef,
} = controller
return (
<Drawer open={avatarDrawerOpen} onOpenChange={setAvatarDrawerOpen}>
<Drawer>
<main
className={`inspector ${editing ? 'expression-workspace-active' : sequenceEditing ? 'sequence-workspace-active' : bodyEditing ? 'body-workspace' : 'studio-workspace'}${activeSequence && !editorPageOpen ? ' state-player-active' : ''}`}
>
@ -330,11 +328,13 @@ export function StudioInspector({ controller }: { controller: StudioController }
{t(
mode === 'manual'
? 'Pose'
: mode === 'expressions'
? 'Expressions'
: mode === 'states'
? 'Animations'
: 'Exporter'
: mode === 'avatars'
? 'Avatars'
: mode === 'expressions'
? 'Expressions'
: mode === 'states'
? 'Animations'
: 'Exporter'
)}
</h1>
</header>
@ -1229,6 +1229,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
)}
</div>
)}
{!editorPageOpen && mode === 'avatars' && <AvatarPage controller={controller} />}
{!sequenceEditing && !editing && bodyEditing && (
<footer className="workspace-footer">
@ -1921,9 +1922,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
className="mobile-mode-tab mobile-avatar-tab"
variant="ghost"
type="button"
aria-pressed={avatarDrawerOpen}
aria-pressed={mode === 'avatars'}
aria-label={t('Choisir un avatar')}
onClick={() => setAvatarDrawerOpen(true)}
onClick={() => setMode('avatars')}
>
<ExpressionPreview
expression={expressions[0] ?? defaultExpression}
@ -1957,7 +1958,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
))}
</nav>
)}
<AvatarDrawer controller={controller} onOpenChange={setAvatarDrawerOpen} />
</main>
</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 { StudioInspector } from '@/features/studio/components/StudioInspector'
import { StudioStage } from '@/features/studio/components/StudioStage'
import type { StudioController } from '@/features/studio/useStudioController'
const STACKED_LAYOUT_QUERY = '(max-width: 980px)'
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 (
<div className="studio" lang={controller.language}>
<StudioStage controller={controller} />
<StudioInspector controller={controller} />
<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} />
<StudioInspector controller={controller} />
</div>
)}
<StudioDialogs controller={controller} />
</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.':
'Open this demo directly, or serve the folder locally to import avatar.js from another module.',
'Langue de l’interface': 'Interface language',
'Redimensionner l’aperçu et l’éditeur': 'Resize preview and editor',
'Nom de l’avatar': 'Avatar name',
sélecteur: 'picker',
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.':
'可直接打开此演示,或在本地启动该目录,以便从其他模块导入 avatar.js。',
'Langue de l’interface': '界面语言',
'Redimensionner l’aperçu et l’éditeur': '调整预览和编辑器大小',
'Nom de l’avatar': '头像名称',
sélecteur: '选择器',
hexadécimal: '十六进制值',