Add draggable level reordering to floating selector

This commit is contained in:
sudhir
2026-05-01 14:55:24 +05:30
parent b3f7957076
commit eee7af9e25
3 changed files with 230 additions and 53 deletions
+11
View File
@@ -79,6 +79,9 @@
"name": "@pascal-app/editor", "name": "@pascal-app/editor",
"version": "0.6.0", "version": "0.6.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@number-flow/react": "^0.5.14", "@number-flow/react": "^0.5.14",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -278,6 +281,14 @@
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
+3
View File
@@ -21,6 +21,9 @@
"three": "^0.184" "three": "^0.184"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@number-flow/react": "^0.5.14", "@number-flow/react": "^0.5.14",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -1,5 +1,23 @@
'use client' '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 { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
@@ -8,8 +26,15 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Copy, MoreVertical, Plus, Trash2 } from 'lucide-react' import { Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import {
type ButtonHTMLAttributes,
type CSSProperties,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { import {
buildLevelDuplicateCreateOps, buildLevelDuplicateCreateOps,
@@ -96,12 +121,18 @@ function LevelInlineRename({
function LevelRow({ function LevelRow({
level, level,
isSelected, isSelected,
isDragging,
dragHandleProps,
dragHandleRef,
onSelect, onSelect,
onDuplicate, onDuplicate,
onRequestDelete, onRequestDelete,
}: { }: {
level: LevelNode level: LevelNode
isSelected: boolean isSelected: boolean
isDragging?: boolean
dragHandleProps?: ButtonHTMLAttributes<HTMLButtonElement>
dragHandleRef?: (element: HTMLButtonElement | null) => void
onSelect: () => void onSelect: () => void
onDuplicate: (preset?: LevelDuplicatePreset) => void onDuplicate: (preset?: LevelDuplicatePreset) => void
onRequestDelete: () => void onRequestDelete: () => void
@@ -121,13 +152,32 @@ function LevelRow({
<div <div
className={cn( className={cn(
'flex items-center rounded-lg transition-colors', 'flex items-center rounded-lg transition-colors',
isDragging && 'bg-white/10 text-foreground shadow-lg',
isSelected isSelected
? 'bg-white/10 text-foreground' ? 'bg-white/10 text-foreground'
: 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground', : 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground',
)} )}
> >
<button <button
className="flex min-w-0 flex-1 items-center justify-start px-2.5 py-1.5 font-medium text-xs" {...dragHandleProps}
aria-label={`Reorder ${getLevelDisplayLabel(level)}`}
className={cn(
'ml-0.5 flex h-6 w-4 shrink-0 touch-none cursor-grab items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100',
isDragging && 'cursor-grabbing opacity-100',
)}
onClick={(e) => {
e.stopPropagation()
dragHandleProps?.onClick?.(e)
}}
ref={dragHandleRef}
title="Drag to reorder"
type="button"
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<button
className="flex min-w-0 flex-1 items-center justify-start py-1.5 pr-2 pl-1 font-medium text-xs"
onClick={onSelect} onClick={onSelect}
onDoubleClick={(e) => { onDoubleClick={(e) => {
e.stopPropagation() e.stopPropagation()
@@ -201,6 +251,53 @@ function LevelRow({
) )
} }
function SortableLevelRow({
level,
isSelected,
onSelect,
onDuplicate,
onRequestDelete,
}: {
level: LevelNode
isSelected: boolean
onSelect: () => void
onDuplicate: (preset?: LevelDuplicatePreset) => 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 (
<div ref={setNodeRef} style={style}>
<LevelRow
dragHandleProps={{ ...attributes, ...listeners }}
dragHandleRef={setActivatorNodeRef}
isDragging={isDragging}
isSelected={isSelected}
level={level}
onDuplicate={onDuplicate}
onRequestDelete={onRequestDelete}
onSelect={onSelect}
/>
</div>
)
}
// ── Main component ────────────────────────────────────────────────────────── // ── Main component ──────────────────────────────────────────────────────────
export function FloatingLevelSelector() { export function FloatingLevelSelector() {
@@ -212,6 +309,15 @@ export function FloatingLevelSelector() {
const updateNodes = useScene((s) => s.updateNodes) const updateNodes = useScene((s) => s.updateNodes)
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null) const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 4 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const resolvedBuildingId = useScene((state) => { const resolvedBuildingId = useScene((state) => {
if (selectedBuildingId) return selectedBuildingId if (selectedBuildingId) return selectedBuildingId
@@ -318,9 +424,52 @@ export function FloatingLevelSelector() {
[createNodes, levels, resolvedBuildingId, setSelection, updateNodes], [createNodes, levels, resolvedBuildingId, setSelection, updateNodes],
) )
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<AnyNode>,
}))
.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 if (levels.length === 0) return null
const reversedLevels = [...levels].reverse() const reversedLevels = [...levels].reverse()
const sortableLevelIds = reversedLevels.map((level) => level.id)
const addButtonClass = 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' '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'
@@ -330,62 +479,76 @@ export function FloatingLevelSelector() {
<div className="pointer-events-auto absolute top-14 left-3 z-20"> <div className="pointer-events-auto absolute top-14 left-3 z-20">
<div className="relative"> <div className="relative">
{/* Floating + at top edge */} {/* Floating + at top edge */}
<button {!draggingLevelId && (
className={cn(addButtonClass, 'top-0 -translate-y-1/2')} <button
onClick={handleAddAbove} className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
title="Add level above" onClick={handleAddAbove}
type="button" title="Add level above"
> type="button"
<Plus className="h-2.5 w-2.5" /> >
</button> <Plus className="h-2.5 w-2.5" />
</button>
)}
{/* Floating + at bottom edge */} {/* Floating + at bottom edge */}
<button {!draggingLevelId && (
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')} <button
onClick={handleAddBelow} className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
title="Add level below" onClick={handleAddBelow}
type="button" title="Add level below"
> type="button"
<Plus className="h-2.5 w-2.5" /> >
</button> <Plus className="h-2.5 w-2.5" />
</button>
)}
{/* Level list */} {/* Level list */}
<div className="flex flex-col gap-0.5 rounded-xl border border-border bg-background/90 p-1 shadow-2xl backdrop-blur-md"> <DndContext
{reversedLevels.map((level, i) => { collisionDetection={closestCenter}
const isSelected = level.id === levelId onDragCancel={handleDragCancel}
const sortedIndex = levels.indexOf(level) onDragEnd={handleDragEnd}
const showGapBelow = i < reversedLevels.length - 1 onDragStart={handleDragStart}
sensors={sensors}
>
<SortableContext items={sortableLevelIds} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-0.5 rounded-xl border border-border bg-background/90 p-1 shadow-2xl backdrop-blur-md">
{reversedLevels.map((level, i) => {
const isSelected = level.id === levelId
const sortedIndex = levels.indexOf(level)
const showGapBelow = i < reversedLevels.length - 1
return ( return (
<div className="relative" key={level.id}> <div className="relative" key={level.id}>
<LevelRow <SortableLevelRow
isSelected={isSelected} isSelected={isSelected}
level={level} level={level}
onDuplicate={(preset) => handleDuplicateLevel(level, preset)} onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
onRequestDelete={() => setDeletingLevel(level)} onRequestDelete={() => setDeletingLevel(level)}
onSelect={() => onSelect={() =>
setSelection( setSelection(
resolvedBuildingId resolvedBuildingId
? { buildingId: resolvedBuildingId, levelId: level.id } ? { buildingId: resolvedBuildingId, levelId: level.id }
: { levelId: level.id }, : { levelId: level.id },
) )
} }
/> />
{showGapBelow && ( {showGapBelow && !draggingLevelId && (
<button <button
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')} className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
onClick={() => handleInsertBetween(sortedIndex - 1)} onClick={() => handleInsertBetween(sortedIndex - 1)}
title="Insert level here" title="Insert level here"
type="button" type="button"
> >
<Plus className="h-2.5 w-2.5" /> <Plus className="h-2.5 w-2.5" />
</button> </button>
)} )}
</div> </div>
) )
})} })}
</div> </div>
</SortableContext>
</DndContext>
</div> </div>
</div> </div>