'use client' import { closestCenter, DndContext, type DragEndEvent, type DragStartEvent, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core' import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { type AnyNode, type AnyNodeId, type BuildingNode, LevelNode, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { ClipboardPaste, Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react' import { type ButtonHTMLAttributes, type CSSProperties, useCallback, useEffect, useRef, useState, useSyncExternalStore, } from 'react' import { useShallow } from 'zustand/react/shallow' import { buildLevelDuplicateCreateOps, type LevelDuplicatePreset, } from '../../lib/level-duplication' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' import { getEditorClipboardSnapshot, pasteEditorClipboardToLevel, subscribeEditorClipboard, } from '../../lib/scene-clipboard' import { sfxEmitter } from '../../lib/sfx-bus' import { cn } from '../../lib/utils' import { LevelDuplicateDialog } from './level-duplicate-dialog' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from './primitives/dialog' import { Popover, PopoverContent, PopoverTrigger } from './primitives/popover' function getLevelDisplayLabel(level: LevelNode) { return level.name || `Level ${level.level}` } // ── Inline rename input for a level row ───────────────────────────────────── function LevelInlineRename({ level, isEditing, onStopEditing, }: { level: LevelNode isEditing: boolean onStopEditing: () => void }) { const updateNode = useScene((s) => s.updateNode) const defaultName = `Level ${level.level}` const [value, setValue] = useState(level.name || '') const inputRef = useRef(null) useEffect(() => { if (isEditing) { setValue(level.name || '') setTimeout(() => { inputRef.current?.focus() inputRef.current?.select() }, 0) } }, [isEditing, level.name]) const handleSave = useCallback(() => { const trimmed = value.trim() if (trimmed !== level.name) { updateNode(level.id, { name: trimmed || undefined }) } onStopEditing() }, [value, level.id, level.name, updateNode, onStopEditing]) if (!isEditing) return null return ( setValue(e.target.value)} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() handleSave() } else if (e.key === 'Escape') { e.preventDefault() onStopEditing() } }} placeholder={defaultName} ref={inputRef} type="text" value={value} /> ) } // ── Level row with three-dot menu ─────────────────────────────────────────── function LevelRow({ level, isSelected, isDragging, dragHandleProps, dragHandleRef, onSelect, onDuplicate, onPaste, onRequestDelete, }: { level: LevelNode isSelected: boolean isDragging?: boolean dragHandleProps?: ButtonHTMLAttributes dragHandleRef?: (element: HTMLButtonElement | null) => void onSelect: () => void onDuplicate: (preset?: LevelDuplicatePreset) => void onPaste?: () => void onRequestDelete: () => void }) { const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [isEditing, setIsEditing] = useState(false) return (
{isEditing ? ( setIsEditing(false)} /> ) : (
{/* Vertical three-dot menu — inside the pill */} {onPaste && ( )}
)} { onDuplicate(preset) setDuplicateDialogOpen(false) }} onOpenChange={setDuplicateDialogOpen} open={duplicateDialogOpen} />
) } function SortableLevelRow({ level, isSelected, onSelect, onDuplicate, onPaste, onRequestDelete, }: { level: LevelNode isSelected: boolean onSelect: () => void onDuplicate: (preset?: LevelDuplicatePreset) => void onPaste?: () => void onRequestDelete: () => void }) { const { attributes, isDragging, listeners, setActivatorNodeRef, setNodeRef, transform, transition, } = useSortable({ id: level.id }) const style: CSSProperties = { opacity: isDragging ? 0.86 : undefined, position: 'relative', transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 30 : undefined, } return (
) } // ── Main component ────────────────────────────────────────────────────────── export function FloatingLevelSelector() { const selectedBuildingId = useViewer((s) => s.selection.buildingId) const levelId = useViewer((s) => s.selection.levelId) const setSelection = useViewer((s) => s.setSelection) const createNode = useScene((s) => s.createNode) const createNodes = useScene((s) => s.createNodes) const updateNodes = useScene((s) => s.updateNodes) const [deletingLevel, setDeletingLevel] = useState(null) const [draggingLevelId, setDraggingLevelId] = useState(null) const clipboardSnapshot = useSyncExternalStore( subscribeEditorClipboard, getEditorClipboardSnapshot, getEditorClipboardSnapshot, ) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ) const resolvedBuildingId = useScene((state) => { if (selectedBuildingId) return selectedBuildingId const first = Object.values(state.nodes).find((n) => n?.type === 'building') as | BuildingNode | undefined return first?.id ?? null }) const levels = useScene( useShallow((state) => { if (!resolvedBuildingId) return [] as LevelNode[] const building = state.nodes[resolvedBuildingId] if (!building || building.type !== 'building') return [] as LevelNode[] return (building as BuildingNode).children .map((id) => state.nodes[id]) .filter((node): node is LevelNode => node?.type === 'level') .sort((a, b) => a.level - b.level) }), ) const handleAddAbove = useCallback(() => { if (!resolvedBuildingId) return const maxLevel = levels.length > 0 ? Math.max(...levels.map((l) => l.level)) : -1 const newLevel = LevelNode.parse({ level: maxLevel + 1, children: [], parentId: resolvedBuildingId, }) createNode(newLevel, resolvedBuildingId) setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id }) }, [resolvedBuildingId, levels, createNode, setSelection]) const handleAddBelow = useCallback(() => { if (!resolvedBuildingId) return const minLevel = levels.length > 0 ? Math.min(...levels.map((l) => l.level)) : 1 const newLevel = LevelNode.parse({ level: minLevel - 1, children: [], parentId: resolvedBuildingId, }) createNode(newLevel, resolvedBuildingId) setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id }) }, [resolvedBuildingId, levels, createNode, setSelection]) const handleInsertBetween = useCallback( (lowerIndex: number) => { if (!resolvedBuildingId) return const lower = levels[lowerIndex] if (!lower) return const newLevelNumber = lower.level + 1 const toShift = levels.filter((l) => l.level >= newLevelNumber) if (toShift.length > 0) { updateNodes( toShift.map((l) => ({ id: l.id as AnyNodeId, data: { level: l.level + 1 } as Partial, })), ) } const newLevel = LevelNode.parse({ level: newLevelNumber, children: [], parentId: resolvedBuildingId, }) createNode(newLevel, resolvedBuildingId) setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id }) }, [resolvedBuildingId, levels, createNode, updateNodes, setSelection], ) const handleConfirmDelete = useCallback(() => { if (!deletingLevel) return deleteLevelWithFallbackSelection(deletingLevel.id) setDeletingLevel(null) }, [deletingLevel]) const handleDuplicateLevel = useCallback( (level: LevelNode, preset: LevelDuplicatePreset = 'everything') => { const { createOps, newLevelId, shiftedLevels } = buildLevelDuplicateCreateOps({ nodes: useScene.getState().nodes, level, levels, preset, }) if (shiftedLevels.length > 0) { updateNodes( shiftedLevels.map((shiftedLevel) => ({ id: shiftedLevel.id as AnyNodeId, data: { level: shiftedLevel.level } as Partial, })), ) } createNodes(createOps) setSelection({ buildingId: resolvedBuildingId ?? undefined, levelId: newLevelId as LevelNode['id'], }) }, [createNodes, levels, resolvedBuildingId, setSelection, updateNodes], ) const handlePasteToLevel = useCallback((level: LevelNode) => { const result = pasteEditorClipboardToLevel(level.id) if (result?.pastedIds.length) { sfxEmitter.emit('sfx:item-place') } }, []) const handleDragStart = useCallback((event: DragStartEvent) => { setDraggingLevelId(String(event.active.id)) }, []) const handleDragEnd = useCallback( (event: DragEndEvent) => { setDraggingLevelId(null) const { active, over } = event if (!over || active.id === over.id) return const visualLevels = [...levels].reverse() const oldIndex = visualLevels.findIndex((level) => level.id === active.id) const newIndex = visualLevels.findIndex((level) => level.id === over.id) if (oldIndex === -1 || newIndex === -1) return const reorderedVisualLevels = arrayMove(visualLevels, oldIndex, newIndex) const levelNumbersDescending = levels.map((level) => level.level).sort((a, b) => b - a) const updates = reorderedVisualLevels .map((level, index) => ({ id: level.id as AnyNodeId, nextLevel: levelNumbersDescending[index], data: { level: levelNumbersDescending[index] } as Partial, })) .filter(({ id, nextLevel }) => { const currentLevel = levels.find((level) => level.id === id) return currentLevel?.level !== nextLevel }) .map(({ id, data }) => ({ id, data })) if (updates.length > 0) { updateNodes(updates) } }, [levels, updateNodes], ) const handleDragCancel = useCallback(() => { setDraggingLevelId(null) }, []) if (levels.length === 0) return null const reversedLevels = [...levels].reverse() const sortableLevelIds = reversedLevels.map((level) => level.id) const addButtonClass = 'absolute left-1/2 z-10 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-full border border-border/80 bg-neutral-800 text-muted-foreground/60 shadow-md transition-colors hover:bg-neutral-700 hover:text-foreground' return ( <>
{/* Floating + at top edge */} {!draggingLevelId && ( )} {/* Floating + at bottom edge */} {!draggingLevelId && ( )} {/* Level list */}
{reversedLevels.map((level, i) => { const isSelected = level.id === levelId const sortedIndex = levels.indexOf(level) const showGapBelow = i < reversedLevels.length - 1 return (
handleDuplicateLevel(level, preset)} onPaste={ clipboardSnapshot ? () => handlePasteToLevel(level) : undefined } onRequestDelete={() => setDeletingLevel(level)} onSelect={() => setSelection( resolvedBuildingId ? { buildingId: resolvedBuildingId, levelId: level.id } : { levelId: level.id }, ) } /> {showGapBelow && !draggingLevelId && ( )}
) })}
{/* Delete confirmation dialog */} !open && setDeletingLevel(null)} open={!!deletingLevel}> Delete level Are you sure you want to delete{' '} {deletingLevel ? getLevelDisplayLabel(deletingLevel) : ''}? All walls, floors, and objects on this level will be permanently removed. ) }