openpencil/src/components/MobileDrawer.vue
2026-06-30 19:27:38 +03:00

200 lines
6.5 KiB
Vue

<script setup lang="ts">
import { useElementSize, useWindowSize } from '@vueuse/core'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { motion } from 'motion-v'
import type { PanInfo } from 'motion-v'
import { computed, ref } from 'vue'
import ChatPanel from './ChatPanel.vue'
import CodePanel from './CodePanel.vue'
import DesignPanel from './DesignPanel.vue'
import LayerTree from './LayerTree/LayerTree.vue'
import PagesPanel from './PagesPanel.vue'
import {
DRAWER_SPRING_DAMPING,
DRAWER_SPRING_STIFFNESS,
HALF_FRAC,
HUD_TOP,
SWIPE_THRESHOLD,
SWIPE_VELOCITY_THRESHOLD
} from '@/constants'
import { useEditorStore } from '@/app/editor/active-store'
type Snap = 'closed' | 'half' | 'full'
type DrawerTab = 'layers' | 'design' | 'code' | 'ai'
const store = useEditorStore()
const headerRef = ref<HTMLElement | null>(null)
const { height: headerH } = useElementSize(headerRef, { width: 0, height: 56 })
const { height: windowH } = useWindowSize()
const snap = computed({
get: (): Snap => store.state.mobileDrawerSnap,
set: (v: Snap) => {
store.state.mobileDrawerSnap = v
}
})
function getDrawerTab(): DrawerTab {
if (store.state.activeRibbonTab === 'code') return 'code'
if (store.state.activeRibbonTab === 'ai') return 'ai'
return store.state.panelMode === 'design' ? 'design' : 'layers'
}
function setDrawerTab(tab: DrawerTab) {
if (tab === 'code' || tab === 'ai') {
store.state.activeRibbonTab = tab
return
}
store.state.activeRibbonTab = 'panels'
store.state.panelMode = tab
}
const isOpen = computed(() => snap.value !== 'closed')
function toggleTab(tab: DrawerTab) {
if (getDrawerTab() === tab && isOpen.value) {
snap.value = 'closed'
targetHeight.value = snapHeight('closed')
return
}
setDrawerTab(tab)
if (!isOpen.value) {
snap.value = 'half'
targetHeight.value = snapHeight('half')
}
}
function snapHeight(s: Snap): number {
switch (s) {
case 'full':
return windowH.value - HUD_TOP
case 'half':
return Math.round(windowH.value * HALF_FRAC)
default:
return headerH.value
}
}
const targetHeight = ref(snapHeight(snap.value))
function onPan(_e: PointerEvent, info: PanInfo) {
const maxHeight = snapHeight('full')
const raw = snapHeight(snap.value) - info.offset.y
targetHeight.value = Math.max(headerH.value, Math.min(maxHeight, raw))
}
function onPanEnd(_e: PointerEvent, info: PanInfo) {
const isSwipeUp = info.offset.y < -SWIPE_THRESHOLD || info.velocity.y < -SWIPE_VELOCITY_THRESHOLD
const isSwipeDown = info.offset.y > SWIPE_THRESHOLD || info.velocity.y > SWIPE_VELOCITY_THRESHOLD
if (isSwipeUp) {
if (snap.value === 'closed') snap.value = 'half'
else snap.value = 'full'
} else if (isSwipeDown) {
if (snap.value === 'full') snap.value = 'half'
else snap.value = 'closed'
}
targetHeight.value = snapHeight(snap.value)
}
const drawerTransition = {
type: 'spring' as const,
stiffness: DRAWER_SPRING_STIFFNESS,
damping: DRAWER_SPRING_DAMPING
}
</script>
<template>
<motion.div
data-test-id="mobile-drawer"
class="fixed inset-x-0 bottom-0 z-30 flex touch-none flex-col rounded-t-3xl bg-panel pb-[env(safe-area-inset-bottom)] shadow-[0_-2px_10px_rgba(0,0,0,0.3)]"
:animate="{ height: `${targetHeight}px` }"
:transition="drawerTransition"
@pan="onPan"
@panEnd="onPanEnd"
>
<TabsRoot :model-value="getDrawerTab()" class="flex min-h-0 flex-1 flex-col">
<nav ref="headerRef" aria-label="Mobile panel navigation" class="flex shrink-0 flex-col">
<div class="flex w-full justify-center pt-2">
<div class="h-1 w-8 rounded-full bg-muted/40" />
</div>
<TabsList class="flex w-full items-center px-2 py-2">
<TabsTrigger
data-test-id="mobile-ribbon-layers"
value="layers"
class="flex h-full cursor-pointer items-center justify-center gap-1.5 px-4 text-xs transition-colors outline-none select-none data-[state=active]:text-accent"
@click="toggleTab('layers')"
>
<icon-lucide-layers class="size-4" />
</TabsTrigger>
<TabsTrigger
data-test-id="mobile-ribbon-design"
value="design"
class="flex h-full cursor-pointer items-center justify-center gap-1.5 px-4 text-xs transition-colors outline-none select-none data-[state=active]:text-accent"
@click="toggleTab('design')"
>
<icon-lucide-sliders-horizontal class="size-4" />
</TabsTrigger>
<div class="flex-1" />
<TabsTrigger
data-test-id="mobile-ribbon-code"
value="code"
class="flex h-full cursor-pointer items-center justify-center px-3 transition-colors outline-none select-none data-[state=active]:text-accent"
@click="toggleTab('code')"
>
<icon-lucide-code class="size-4" />
</TabsTrigger>
<TabsTrigger
data-test-id="mobile-ribbon-ai"
value="ai"
class="flex h-full cursor-pointer items-center justify-center px-3 transition-colors outline-none select-none data-[state=active]:text-accent"
@click="toggleTab('ai')"
>
<icon-lucide-sparkles class="size-4" />
</TabsTrigger>
</TabsList>
</nav>
<div data-test-id="mobile-drawer-content" class="min-h-0 flex-1 overflow-y-auto">
<TabsContent value="layers" class="mt-0 h-full data-[state=inactive]:hidden">
<div data-test-id="mobile-drawer-layers" class="flex h-full flex-col">
<PagesPanel />
<div class="border-t border-border" />
<header class="shrink-0 px-3 py-2 text-[11px] tracking-wider text-muted uppercase">
Layers
</header>
<LayerTree class="min-h-0 flex-1" />
</div>
</TabsContent>
<TabsContent value="design" class="mt-0 h-full data-[state=inactive]:hidden">
<div data-test-id="mobile-drawer-design" class="flex h-full flex-col">
<DesignPanel />
</div>
</TabsContent>
<TabsContent value="code" class="mt-0 h-full data-[state=inactive]:hidden">
<div data-test-id="mobile-drawer-code" class="flex h-full flex-col">
<CodePanel />
</div>
</TabsContent>
<TabsContent value="ai" class="mt-0 h-full data-[state=inactive]:hidden">
<div data-test-id="mobile-drawer-ai" class="flex h-full flex-col">
<ChatPanel />
</div>
</TabsContent>
</div>
</TabsRoot>
</motion.div>
</template>