ai-chatbot/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx
Aymeric Rabot aa43bb6a43
feat: backport monorepo PRs #297, #302, #306 — floorplan thumbnails + mobile editor UI
Port floorplan item thumbnails, full mobile editor UI, and
mobile polish from the private monorepo into the public editor.

Monorepo PR #297 (feat/item-admin-advanced):
- Add optional floorPlanUrl to item asset schema (core)
- Render 2D floor-plan images inside item footprints on floorplan
- Improve slider drag with modifier key re-anchoring

Monorepo PR #302 (feat/mobile-ui):
- Mobile editor layout with draggable bottom sheet
- Mobile tab bar, selection bar, and panel sheet
- Mobile-aware panel manager and panel wrapper
- useIsMobile rewrite (useSyncExternalStore, SSR-safe)
- Camera actions hideOrbit prop
- GridSnapControl and SecondaryToggles exports
- Action menu hides on mobile contextual tabs
- SidebarTab extended with mobileDefaultSnap/mobileIcon

Monorepo PR #306 (feat/mobile-ui-polish):
- Touch gesture mapping for camera controls (one/two/three finger)
- Snap ratio constants for bottom sheet
- Thumbnail generator WebGL2 fallback (bottom-up row flip)
- ErrorBoundary scope logging, viewer scene wrap
- GPUDeviceWatcher enhanced logging and uncaptured error handler
- WebGPURenderer init error handling and diagnostics
- Post-processing log improvements
- MergedOutlineNode WebGL2 FBO corruption fix

Excluded: apps/community/*, apps/editor/*, packages/community-*,
.cursor/*, .env.example (monorepo-only files)

Co-authored-by: Pascal <open@pascal.app>
2026-04-28 06:36:08 -07:00

109 lines
3.6 KiB
TypeScript

'use client'
import { X } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import Image from 'next/image'
import { type ReactNode, useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import useEditor from '../../../store/use-editor'
interface MobilePanelSheetProps {
open: boolean
onClose: () => void
icon?: string
title: string
children: ReactNode
}
const HEIGHT_VH = 50
const DRAG_CLOSE_THRESHOLD_PX = 120
export function MobilePanelSheet({ open, onClose, icon, title, children }: MobilePanelSheetProps) {
const [mounted, setMounted] = useState(false)
const setMobilePanelSheetHeight = useEditor((s) => s.setMobilePanelSheetHeight)
useEffect(() => {
setMounted(true)
}, [])
// Publish the sheet's pixel height to the shared store so the mobile layout
// can shrink the viewer container and preview edits live. 0 means closed.
// Tracks visualViewport so the value follows the on-screen keyboard on iOS.
useEffect(() => {
if (!open) {
setMobilePanelSheetHeight(0)
return
}
const compute = () => {
const vh = window.visualViewport?.height ?? window.innerHeight
setMobilePanelSheetHeight(Math.round((vh * HEIGHT_VH) / 100))
}
compute()
const vv = window.visualViewport
vv?.addEventListener('resize', compute)
window.addEventListener('resize', compute)
return () => {
vv?.removeEventListener('resize', compute)
window.removeEventListener('resize', compute)
setMobilePanelSheetHeight(0)
}
}, [open, setMobilePanelSheetHeight])
if (!mounted) return null
return createPortal(
<AnimatePresence>
{open && (
<motion.div
animate={{ y: 0 }}
className="dark fixed right-0 bottom-0 left-0 z-[60] flex flex-col overflow-hidden rounded-t-2xl bg-sidebar text-sidebar-foreground shadow-[0_-8px_24px_rgba(0,0,0,0.24)]"
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.4 }}
exit={{ y: '100%' }}
initial={{ y: '100%' }}
onDragEnd={(_, info) => {
if (info.offset.y > DRAG_CLOSE_THRESHOLD_PX) onClose()
}}
style={{ height: `${HEIGHT_VH}dvh` }}
transition={{ type: 'spring', stiffness: 320, damping: 32, mass: 0.8 }}
>
<div className="flex h-6 shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing">
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
</div>
<div className="flex shrink-0 items-center justify-between border-border/50 border-b px-3 pt-1 pb-3">
<div className="flex min-w-0 items-center gap-2">
{icon && (
<Image
alt=""
className="shrink-0 object-contain"
height={18}
src={icon}
width={18}
/>
)}
<h2 className="truncate font-semibold text-foreground text-sm tracking-tight">
{title}
</h2>
</div>
<button
aria-label="Close"
className="flex h-8 w-8 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
onClick={onClose}
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">
{children}
</div>
</motion.div>
)}
</AnimatePresence>,
document.body,
)
}