Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/src/schema/nodes/item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ const assetSchema = z.object({
category: z.string(),
name: z.string(),
thumbnail: z.string(),
// Optional top-down 2D image shown inside the item's footprint on the
// floor plan. When present, replaces the default diagonal-cross marker.
floorPlanUrl: z.string().optional(),
src: AssetUrl,
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
Expand Down
149 changes: 149 additions & 0 deletions packages/editor/src/components/editor/bottom-sheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
'use client'

import { animate, motion, useMotionValue } from 'motion/react'
import {
forwardRef,
type ReactNode,
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from 'react'

export type BottomSheetHandle = {
snapTo: (heightPx: number) => void
getHeight: () => number
}

interface BottomSheetProps {
initialHeightPx: number
snapPointsPx: number[]
onCommit: (heightPx: number) => void
children: ReactNode
}

const DRAG_THRESHOLD_PX = 6

export const BottomSheet = forwardRef<BottomSheetHandle, BottomSheetProps>(function BottomSheet(
{ initialHeightPx, snapPointsPx, onCommit, children },
ref,
) {
const height = useMotionValue(initialHeightPx)
const dragStartY = useRef<number | null>(null)
const dragStartHeight = useRef(0)
const hasDragged = useRef(false)
const animationRef = useRef<ReturnType<typeof animate> | null>(null)

const clamp = useCallback(
(px: number) => {
const min = Math.min(...snapPointsPx)
const max = Math.max(...snapPointsPx)
return Math.max(min, Math.min(max, px))
},
[snapPointsPx],
)

const nearestSnap = useCallback(
(px: number) => {
let best = snapPointsPx[0] ?? 0
let bestDist = Number.POSITIVE_INFINITY
for (const p of snapPointsPx) {
const d = Math.abs(p - px)
if (d < bestDist) {
bestDist = d
best = p
}
}
return best
},
[snapPointsPx],
)

const animateTo = useCallback(
(targetPx: number) => {
animationRef.current?.stop()
const controls = animate(height, targetPx, {
type: 'spring',
stiffness: 320,
damping: 32,
mass: 0.8,
onComplete: () => {
onCommit(targetPx)
},
})
animationRef.current = controls
},
[height, onCommit],
)

useImperativeHandle(
ref,
() => ({
snapTo: (px: number) => animateTo(clamp(px)),
getHeight: () => height.get(),
}),
[animateTo, clamp, height],
)

const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (e.button !== 0 && e.pointerType === 'mouse') return
e.currentTarget.setPointerCapture(e.pointerId)
animationRef.current?.stop()
dragStartY.current = e.clientY
dragStartHeight.current = height.get()
hasDragged.current = false
},
[height],
)

const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (dragStartY.current === null) return
const dy = e.clientY - dragStartY.current
if (!hasDragged.current && Math.abs(dy) < DRAG_THRESHOLD_PX) return
hasDragged.current = true
const next = clamp(dragStartHeight.current - dy)
height.set(next)
},
[clamp, height],
)

const endDrag = useCallback(
(e: React.PointerEvent) => {
if (dragStartY.current === null) return
dragStartY.current = null
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
if (!hasDragged.current) return
const target = nearestSnap(height.get())
animateTo(target)
},
[animateTo, height, nearestSnap],
)

useEffect(() => {
return () => {
animationRef.current?.stop()
}
}, [])

return (
<motion.div
className="absolute right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden rounded-t-2xl bg-sidebar text-sidebar-foreground shadow-[0_-4px_16px_rgba(0,0,0,0.12)]"
style={{ height }}
>
<div
className="flex h-6 shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing"
onPointerCancel={endDrag}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
>
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">{children}</div>
</motion.div>
)
})
44 changes: 44 additions & 0 deletions packages/editor/src/components/editor/custom-camera-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,49 @@ export const CustomCameraControls = () => {
}
}, [cameraMode, isPreviewMode])

// Touch gestures (mobile / trackpad).
// - One finger drag → rotate by default (much easier on a phone), but
// falls back to NONE while the user is actively
// placing/moving something OR in box-select mode,
// so the editor's pointer handlers (place tool,
// drag-to-move endpoint, marquee selection drag)
// keep priority over the camera.
// In preview mode it's TOUCH_TRUCK (pan), matching
// preview's left = SCREEN_PAN.
// - Two finger pinch → zoom + pan together (TOUCH_DOLLY_TRUCK for
// perspective, TOUCH_ZOOM_TRUCK for orthographic).
// - Three finger drag → rotate, so the camera is always orbitable even
// when one-finger is suppressed by an active
// editor action.
const tool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode)
const selectionTool = useEditor((s) => s.floorplanSelectionTool)
const movingNode = useEditor((s) => s.movingNode)
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
const isInteracting = Boolean(
tool || movingNode || movingWallEndpoint || movingFenceEndpoint || isBoxSelectActive,
)
const touches = useMemo(() => {
const twoFingerAction =
cameraMode === 'orthographic'
? CameraControlsImpl.ACTION.TOUCH_ZOOM_TRUCK
: CameraControlsImpl.ACTION.TOUCH_DOLLY_TRUCK

const oneFingerAction = isPreviewMode
? CameraControlsImpl.ACTION.TOUCH_TRUCK
: isInteracting
? CameraControlsImpl.ACTION.NONE
: CameraControlsImpl.ACTION.TOUCH_ROTATE

return {
one: oneFingerAction,
two: twoFingerAction,
three: CameraControlsImpl.ACTION.TOUCH_ROTATE,
}
}, [cameraMode, isPreviewMode, isInteracting])

useEffect(() => {
const keyState = {
shiftRight: false,
Expand Down Expand Up @@ -407,6 +450,7 @@ export const CustomCameraControls = () => {
onTransitionStart={onTransitionStart}
ref={controls}
restThreshold={0.01}
touches={touches}
/>
)
}
Loading
Loading