200 lines
6.5 KiB
Vue
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>
|