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:
parent
9fcfec9076
commit
398c7f53e2
@ -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
14
pnpm-lock.yaml
generated
@ -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: {}
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
40
src/components/ui/resizable.tsx
Normal file
40
src/components/ui/resizable.tsx
Normal 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 }
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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: '十六进制值',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user