@pascal-app/editor 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/package.json +9 -5
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +20 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9855 -3298
  12. package/src/components/editor/index.tsx +269 -21
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/thumbnail-generator.tsx +38 -7
  15. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  16. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  17. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  18. package/src/components/editor/wall-measurement-label.tsx +267 -36
  19. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  20. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  21. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  22. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  23. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  24. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  25. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  26. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  27. package/src/components/editor-2d/svg-paths.ts +119 -0
  28. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  29. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  30. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  31. package/src/components/tools/column/column-tool.tsx +97 -0
  32. package/src/components/tools/column/move-column-tool.tsx +105 -0
  33. package/src/components/tools/door/door-tool.tsx +7 -0
  34. package/src/components/tools/door/move-door-tool.tsx +28 -8
  35. package/src/components/tools/fence/fence-drafting.ts +10 -3
  36. package/src/components/tools/fence/fence-tool.tsx +159 -3
  37. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
  38. package/src/components/tools/fence/move-fence-tool.tsx +101 -34
  39. package/src/components/tools/item/move-tool.tsx +10 -1
  40. package/src/components/tools/item/placement-math.ts +30 -1
  41. package/src/components/tools/item/placement-strategies.ts +109 -31
  42. package/src/components/tools/item/placement-types.ts +7 -0
  43. package/src/components/tools/item/use-draft-node.ts +2 -0
  44. package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
  45. package/src/components/tools/roof/move-roof-tool.tsx +22 -15
  46. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  47. package/src/components/tools/shared/segment-angle.ts +156 -0
  48. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  49. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  50. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  51. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  52. package/src/components/tools/tool-manager.tsx +18 -3
  53. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
  54. package/src/components/tools/wall/wall-drafting.ts +18 -9
  55. package/src/components/tools/wall/wall-tool.tsx +134 -2
  56. package/src/components/tools/window/move-window-tool.tsx +18 -0
  57. package/src/components/tools/window/window-tool.tsx +5 -0
  58. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  59. package/src/components/ui/action-menu/control-modes.tsx +28 -1
  60. package/src/components/ui/action-menu/index.tsx +91 -1
  61. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  63. package/src/components/ui/command-palette/editor-commands.tsx +18 -1
  64. package/src/components/ui/controls/material-picker.tsx +152 -165
  65. package/src/components/ui/controls/slider-control.tsx +66 -18
  66. package/src/components/ui/floating-level-selector.tsx +286 -55
  67. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  68. package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
  69. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  70. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  71. package/src/components/ui/panels/ceiling-panel.tsx +1 -25
  72. package/src/components/ui/panels/column-panel.tsx +715 -0
  73. package/src/components/ui/panels/door-panel.tsx +981 -289
  74. package/src/components/ui/panels/fence-panel.tsx +3 -45
  75. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  76. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  77. package/src/components/ui/panels/node-display.ts +39 -0
  78. package/src/components/ui/panels/paint-panel.tsx +138 -0
  79. package/src/components/ui/panels/panel-manager.tsx +210 -1
  80. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  81. package/src/components/ui/panels/reference-panel.tsx +238 -5
  82. package/src/components/ui/panels/roof-panel.tsx +4 -105
  83. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  84. package/src/components/ui/panels/slab-panel.tsx +4 -30
  85. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  86. package/src/components/ui/panels/stair-panel.tsx +11 -117
  87. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  88. package/src/components/ui/panels/wall-panel.tsx +1 -95
  89. package/src/components/ui/panels/window-panel.tsx +660 -139
  90. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  91. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  92. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  93. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  94. package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
  95. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  96. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  97. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
  98. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
  99. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  100. package/src/components/ui/viewer-toolbar.tsx +42 -1
  101. package/src/hooks/use-auto-frame.ts +45 -0
  102. package/src/hooks/use-keyboard.ts +64 -7
  103. package/src/hooks/use-mobile.ts +12 -12
  104. package/src/lib/door-interaction.ts +88 -0
  105. package/src/lib/floorplan/geometry.ts +263 -0
  106. package/src/lib/floorplan/index.ts +38 -0
  107. package/src/lib/floorplan/items.ts +179 -0
  108. package/src/lib/floorplan/selection-tool.ts +231 -0
  109. package/src/lib/floorplan/stairs.ts +478 -0
  110. package/src/lib/floorplan/types.ts +57 -0
  111. package/src/lib/floorplan/walls.ts +23 -0
  112. package/src/lib/guide-events.ts +10 -0
  113. package/src/lib/level-duplication.test.ts +72 -0
  114. package/src/lib/level-duplication.ts +153 -0
  115. package/src/lib/local-guide-image.ts +42 -0
  116. package/src/lib/material-paint.ts +284 -0
  117. package/src/lib/roof-duplication.ts +214 -0
  118. package/src/lib/scene-bounds.test.ts +183 -0
  119. package/src/lib/scene-bounds.ts +169 -0
  120. package/src/lib/stair-duplication.ts +126 -0
  121. package/src/lib/window-interaction.ts +86 -0
  122. package/src/store/use-editor.tsx +164 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pascal-app/editor",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Pascal building editor component",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,8 +11,8 @@
11
11
  "check-types": "tsc --noEmit"
12
12
  },
13
13
  "peerDependencies": {
14
- "@pascal-app/core": "^0.6.0",
15
- "@pascal-app/viewer": "^0.6.0",
14
+ "@pascal-app/core": "^0.7.0",
15
+ "@pascal-app/viewer": "^0.7.0",
16
16
  "@react-three/drei": "^10",
17
17
  "@react-three/fiber": "^9",
18
18
  "next": ">=15",
@@ -21,6 +21,9 @@
21
21
  "three": "^0.184"
22
22
  },
23
23
  "dependencies": {
24
+ "@dnd-kit/core": "^6.3.1",
25
+ "@dnd-kit/sortable": "^10.0.0",
26
+ "@dnd-kit/utilities": "^3.2.2",
24
27
  "@iconify/react": "^6.0.2",
25
28
  "@number-flow/react": "^0.5.14",
26
29
  "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -46,12 +49,13 @@
46
49
  "motion": "^12.34.3",
47
50
  "nanoid": "^5.1.6",
48
51
  "tailwind-merge": "^3.5.0",
52
+ "three-mesh-bvh": "^0.9.8",
49
53
  "zod": "^4.3.6",
50
54
  "zustand": "^5.0.11"
51
55
  },
52
56
  "devDependencies": {
53
- "@pascal-app/core": "^0.6.0",
54
- "@pascal-app/viewer": "^0.6.0",
57
+ "@pascal-app/core": "^0.7.0",
58
+ "@pascal-app/viewer": "^0.7.0",
55
59
  "@pascal/typescript-config": "*",
56
60
  "@types/howler": "^2.2.12",
57
61
  "@types/node": "^22.19.12",
@@ -0,0 +1,149 @@
1
+ 'use client'
2
+
3
+ import { animate, motion, useMotionValue } from 'motion/react'
4
+ import {
5
+ forwardRef,
6
+ type ReactNode,
7
+ useCallback,
8
+ useEffect,
9
+ useImperativeHandle,
10
+ useRef,
11
+ } from 'react'
12
+
13
+ export type BottomSheetHandle = {
14
+ snapTo: (heightPx: number) => void
15
+ getHeight: () => number
16
+ }
17
+
18
+ interface BottomSheetProps {
19
+ initialHeightPx: number
20
+ snapPointsPx: number[]
21
+ onCommit: (heightPx: number) => void
22
+ children: ReactNode
23
+ }
24
+
25
+ const DRAG_THRESHOLD_PX = 6
26
+
27
+ export const BottomSheet = forwardRef<BottomSheetHandle, BottomSheetProps>(function BottomSheet(
28
+ { initialHeightPx, snapPointsPx, onCommit, children },
29
+ ref,
30
+ ) {
31
+ const height = useMotionValue(initialHeightPx)
32
+ const dragStartY = useRef<number | null>(null)
33
+ const dragStartHeight = useRef(0)
34
+ const hasDragged = useRef(false)
35
+ const animationRef = useRef<ReturnType<typeof animate> | null>(null)
36
+
37
+ const clamp = useCallback(
38
+ (px: number) => {
39
+ const min = Math.min(...snapPointsPx)
40
+ const max = Math.max(...snapPointsPx)
41
+ return Math.max(min, Math.min(max, px))
42
+ },
43
+ [snapPointsPx],
44
+ )
45
+
46
+ const nearestSnap = useCallback(
47
+ (px: number) => {
48
+ let best = snapPointsPx[0] ?? 0
49
+ let bestDist = Number.POSITIVE_INFINITY
50
+ for (const p of snapPointsPx) {
51
+ const d = Math.abs(p - px)
52
+ if (d < bestDist) {
53
+ bestDist = d
54
+ best = p
55
+ }
56
+ }
57
+ return best
58
+ },
59
+ [snapPointsPx],
60
+ )
61
+
62
+ const animateTo = useCallback(
63
+ (targetPx: number) => {
64
+ animationRef.current?.stop()
65
+ const controls = animate(height, targetPx, {
66
+ type: 'spring',
67
+ stiffness: 320,
68
+ damping: 32,
69
+ mass: 0.8,
70
+ onComplete: () => {
71
+ onCommit(targetPx)
72
+ },
73
+ })
74
+ animationRef.current = controls
75
+ },
76
+ [height, onCommit],
77
+ )
78
+
79
+ useImperativeHandle(
80
+ ref,
81
+ () => ({
82
+ snapTo: (px: number) => animateTo(clamp(px)),
83
+ getHeight: () => height.get(),
84
+ }),
85
+ [animateTo, clamp, height],
86
+ )
87
+
88
+ const handlePointerDown = useCallback(
89
+ (e: React.PointerEvent) => {
90
+ if (e.button !== 0 && e.pointerType === 'mouse') return
91
+ e.currentTarget.setPointerCapture(e.pointerId)
92
+ animationRef.current?.stop()
93
+ dragStartY.current = e.clientY
94
+ dragStartHeight.current = height.get()
95
+ hasDragged.current = false
96
+ },
97
+ [height],
98
+ )
99
+
100
+ const handlePointerMove = useCallback(
101
+ (e: React.PointerEvent) => {
102
+ if (dragStartY.current === null) return
103
+ const dy = e.clientY - dragStartY.current
104
+ if (!hasDragged.current && Math.abs(dy) < DRAG_THRESHOLD_PX) return
105
+ hasDragged.current = true
106
+ const next = clamp(dragStartHeight.current - dy)
107
+ height.set(next)
108
+ },
109
+ [clamp, height],
110
+ )
111
+
112
+ const endDrag = useCallback(
113
+ (e: React.PointerEvent) => {
114
+ if (dragStartY.current === null) return
115
+ dragStartY.current = null
116
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
117
+ e.currentTarget.releasePointerCapture(e.pointerId)
118
+ }
119
+ if (!hasDragged.current) return
120
+ const target = nearestSnap(height.get())
121
+ animateTo(target)
122
+ },
123
+ [animateTo, height, nearestSnap],
124
+ )
125
+
126
+ useEffect(() => {
127
+ return () => {
128
+ animationRef.current?.stop()
129
+ }
130
+ }, [])
131
+
132
+ return (
133
+ <motion.div
134
+ 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)]"
135
+ style={{ height }}
136
+ >
137
+ <div
138
+ className="flex h-6 shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing"
139
+ onPointerCancel={endDrag}
140
+ onPointerDown={handlePointerDown}
141
+ onPointerMove={handlePointerMove}
142
+ onPointerUp={endDrag}
143
+ >
144
+ <div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
145
+ </div>
146
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">{children}</div>
147
+ </motion.div>
148
+ )
149
+ })
@@ -1,7 +1,12 @@
1
1
  'use client'
2
-
3
- import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
4
- import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer'
2
+ import {
3
+ type CameraControlEvent,
4
+ type CameraControlFitSceneEvent,
5
+ emitter,
6
+ sceneRegistry,
7
+ useScene,
8
+ } from '@pascal-app/core'
9
+ import { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
5
10
  import { CameraControls, CameraControlsImpl } from '@react-three/drei'
6
11
  import { useThree } from '@react-three/fiber'
7
12
  import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -22,7 +27,7 @@ const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
22
27
  export const CustomCameraControls = () => {
23
28
  const controls = useRef<CameraControlsImpl>(null!)
24
29
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
25
- const walkthroughMode = useViewer((s) => s.walkthroughMode)
30
+ const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
26
31
  const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
27
32
  const selection = useViewer((s) => s.selection)
28
33
  const currentLevelId = selection.levelId
@@ -112,6 +117,49 @@ export const CustomCameraControls = () => {
112
117
  }
113
118
  }, [cameraMode, isPreviewMode])
114
119
 
120
+ // Touch gestures (mobile / trackpad).
121
+ // - One finger drag → rotate by default (much easier on a phone), but
122
+ // falls back to NONE while the user is actively
123
+ // placing/moving something OR in box-select mode,
124
+ // so the editor's pointer handlers (place tool,
125
+ // drag-to-move endpoint, marquee selection drag)
126
+ // keep priority over the camera.
127
+ // In preview mode it's TOUCH_TRUCK (pan), matching
128
+ // preview's left = SCREEN_PAN.
129
+ // - Two finger pinch → zoom + pan together (TOUCH_DOLLY_TRUCK for
130
+ // perspective, TOUCH_ZOOM_TRUCK for orthographic).
131
+ // - Three finger drag → rotate, so the camera is always orbitable even
132
+ // when one-finger is suppressed by an active
133
+ // editor action.
134
+ const tool = useEditor((s) => s.tool)
135
+ const mode = useEditor((s) => s.mode)
136
+ const selectionTool = useEditor((s) => s.floorplanSelectionTool)
137
+ const movingNode = useEditor((s) => s.movingNode)
138
+ const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
139
+ const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
140
+ const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
141
+ const isInteracting = Boolean(
142
+ tool || movingNode || movingWallEndpoint || movingFenceEndpoint || isBoxSelectActive,
143
+ )
144
+ const touches = useMemo(() => {
145
+ const twoFingerAction =
146
+ cameraMode === 'orthographic'
147
+ ? CameraControlsImpl.ACTION.TOUCH_ZOOM_TRUCK
148
+ : CameraControlsImpl.ACTION.TOUCH_DOLLY_TRUCK
149
+
150
+ const oneFingerAction = isPreviewMode
151
+ ? CameraControlsImpl.ACTION.TOUCH_TRUCK
152
+ : isInteracting
153
+ ? CameraControlsImpl.ACTION.NONE
154
+ : CameraControlsImpl.ACTION.TOUCH_ROTATE
155
+
156
+ return {
157
+ one: oneFingerAction,
158
+ two: twoFingerAction,
159
+ three: CameraControlsImpl.ACTION.TOUCH_ROTATE,
160
+ }
161
+ }, [cameraMode, isPreviewMode, isInteracting])
162
+
115
163
  useEffect(() => {
116
164
  const keyState = {
117
165
  shiftRight: false,
@@ -340,12 +388,30 @@ export const CustomCameraControls = () => {
340
388
  focusNode(nodeId)
341
389
  }
342
390
 
391
+ const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
392
+ if (!controls.current || isPreviewMode) return
393
+ if (!bounds) {
394
+ // Restore default framing pose when no bounds were computed.
395
+ controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
396
+ return
397
+ }
398
+ const [cx, cz] = bounds.center
399
+ const [w, d] = bounds.size
400
+ // Use the longer horizontal extent to size the orbit radius so the whole
401
+ // footprint sits in view regardless of aspect ratio.
402
+ const maxExtent = Math.max(w, d)
403
+ const distance = Math.max(maxExtent * 1.4, 15)
404
+ const height = Math.max(maxExtent * 0.8, 10)
405
+ controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
406
+ }
407
+
343
408
  emitter.on('camera-controls:capture', handleNodeCapture)
344
409
  emitter.on('camera-controls:focus', handleNodeFocus)
345
410
  emitter.on('camera-controls:view', handleNodeView)
346
411
  emitter.on('camera-controls:top-view', handleTopView)
347
412
  emitter.on('camera-controls:orbit-cw', handleOrbitCW)
348
413
  emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)
414
+ emitter.on('camera-controls:fit-scene', handleFitScene)
349
415
 
350
416
  return () => {
351
417
  emitter.off('camera-controls:capture', handleNodeCapture)
@@ -354,8 +420,9 @@ export const CustomCameraControls = () => {
354
420
  emitter.off('camera-controls:top-view', handleTopView)
355
421
  emitter.off('camera-controls:orbit-cw', handleOrbitCW)
356
422
  emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
423
+ emitter.off('camera-controls:fit-scene', handleFitScene)
357
424
  }
358
- }, [focusNode])
425
+ }, [focusNode, isPreviewMode])
359
426
 
360
427
  const onTransitionStart = useCallback(() => {
361
428
  useViewer.getState().setCameraDragging(true)
@@ -365,8 +432,8 @@ export const CustomCameraControls = () => {
365
432
  useViewer.getState().setCameraDragging(false)
366
433
  }, [])
367
434
 
368
- if (walkthroughMode) {
369
- return <WalkthroughControls />
435
+ if (isFirstPersonMode) {
436
+ return null
370
437
  }
371
438
 
372
439
  return (
@@ -382,6 +449,7 @@ export const CustomCameraControls = () => {
382
449
  onTransitionStart={onTransitionStart}
383
450
  ref={controls}
384
451
  restThreshold={0.01}
452
+ touches={touches}
385
453
  />
386
454
  )
387
455
  }
@@ -0,0 +1,264 @@
1
+ 'use client'
2
+
3
+ import { useViewer } from '@pascal-app/viewer'
4
+ import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
5
+ import useEditor from '../../store/use-editor'
6
+ import { MobileTabBar } from '../ui/sidebar/mobile-tab-bar'
7
+ import type { SidebarTab } from '../ui/sidebar/tab-bar'
8
+ import { BottomSheet, type BottomSheetHandle } from './bottom-sheet'
9
+
10
+ const MIN_SNAP = 0
11
+ const MAX_SNAP = 1
12
+ const DEFAULT_SNAP = 0.5
13
+ // Viewer extends this many pixels behind the sheet's rounded top corners
14
+ // so the curve reveals viewer content underneath.
15
+ const SHEET_OVERLAP_PX = 16
16
+ // Sheet never collapses below the drag handle so the user can always grab it.
17
+ const SHEET_HANDLE_PX = 24
18
+
19
+ // Match the viewer's scene background colors (packages/viewer/src/components/viewer/index.tsx)
20
+ const VIEWER_BG_DARK = '#1f2433'
21
+ const VIEWER_BG_LIGHT = '#ffffff'
22
+
23
+ // Fixed set of intermediate snap heights (handle + middleH are added on top).
24
+ // Per-tab `mobileDefaultSnap` decides the OPENING height; this list bounds
25
+ // what the user can drag to.
26
+ const SNAP_RATIOS = [0.5, 0.66] as const
27
+
28
+ function getDefaultSnap(tab: SidebarTab | undefined): number {
29
+ const s = tab?.mobileDefaultSnap
30
+ if (typeof s !== 'number') return DEFAULT_SNAP
31
+ return Math.max(MIN_SNAP, Math.min(MAX_SNAP, s))
32
+ }
33
+
34
+ export interface EditorLayoutMobileProps {
35
+ navbarSlot?: ReactNode
36
+ sidebarTabs?: SidebarTab[]
37
+ renderTabContent: (tabId: string) => ReactNode
38
+ sidebarOverlay?: ReactNode
39
+ viewerToolbarLeft?: ReactNode
40
+ viewerToolbarRight?: ReactNode
41
+ viewerContent: ReactNode
42
+ overlays?: ReactNode
43
+ }
44
+
45
+ export function EditorLayoutMobile({
46
+ navbarSlot,
47
+ sidebarTabs = [],
48
+ renderTabContent,
49
+ sidebarOverlay,
50
+ viewerToolbarLeft,
51
+ viewerToolbarRight,
52
+ viewerContent,
53
+ overlays,
54
+ }: EditorLayoutMobileProps) {
55
+ const isCaptureMode = useEditor((s) => s.isCaptureMode)
56
+ const activePanel = useEditor((s) => s.activeSidebarPanel)
57
+ const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
58
+ const panelSheetHeight = useEditor((s) => s.mobilePanelSheetHeight)
59
+ const theme = useViewer((s) => s.theme)
60
+ const viewerBg = theme === 'light' ? VIEWER_BG_LIGHT : VIEWER_BG_DARK
61
+
62
+ const middleRef = useRef<HTMLDivElement>(null)
63
+ const sheetRef = useRef<BottomSheetHandle>(null)
64
+ const [middleH, setMiddleH] = useState(0)
65
+ // Distance from the middle area's bottom edge to the viewport's bottom edge
66
+ // (i.e. the tab bar height incl. safe area). Needed to translate the panel
67
+ // sheet's viewport-relative height into middle-area coordinates.
68
+ const [middleBottomFromViewport, setMiddleBottomFromViewport] = useState(0)
69
+ const [committedSheetH, setCommittedSheetH] = useState(0)
70
+
71
+ const currentTab = sidebarTabs.find((t) => t.id === activePanel)
72
+
73
+ // Keep active panel valid
74
+ useEffect(() => {
75
+ if (sidebarTabs.length > 0 && !sidebarTabs.some((t) => t.id === activePanel)) {
76
+ setActivePanel(sidebarTabs[0]!.id)
77
+ }
78
+ }, [sidebarTabs, activePanel, setActivePanel])
79
+
80
+ // Sync editor phase / mode with the active tab:
81
+ // - Entering Chat always drops to Select (chat is a composing context).
82
+ // - Entering Items snaps the editor into furnish-build (matches the
83
+ // desktop "Furnish" action which itself opens the Items panel).
84
+ // - Leaving Items while still furnishing exits the build mode.
85
+ useEffect(() => {
86
+ const { phase, mode, setMode, setPhase } = useEditor.getState()
87
+ if (activePanel === 'ai' && mode === 'build') {
88
+ setMode('select')
89
+ return
90
+ }
91
+ if (activePanel === 'items') {
92
+ if (phase !== 'furnish') setPhase('furnish')
93
+ if (mode !== 'build') setMode('build')
94
+ return
95
+ }
96
+ if (phase === 'furnish' && mode === 'build') {
97
+ setMode('select')
98
+ }
99
+ }, [activePanel])
100
+
101
+ // Measure middle area height + its bottom offset from viewport bottom
102
+ useLayoutEffect(() => {
103
+ const el = middleRef.current
104
+ if (!el) return
105
+ const measure = () => {
106
+ const rect = el.getBoundingClientRect()
107
+ setMiddleH(rect.height)
108
+ setMiddleBottomFromViewport(Math.max(0, window.innerHeight - rect.bottom))
109
+ }
110
+ const ro = new ResizeObserver(measure)
111
+ ro.observe(el)
112
+ measure()
113
+ window.addEventListener('resize', measure)
114
+ return () => {
115
+ ro.disconnect()
116
+ window.removeEventListener('resize', measure)
117
+ }
118
+ }, [])
119
+
120
+ // Initialise sheet to current tab default once we know the middle height
121
+ const didInit = useRef(false)
122
+ useEffect(() => {
123
+ if (didInit.current || middleH <= 0) return
124
+ didInit.current = true
125
+ const targetPx = getDefaultSnap(currentTab) * middleH
126
+ setCommittedSheetH(targetPx)
127
+ sheetRef.current?.snapTo(targetPx)
128
+ }, [middleH, currentTab])
129
+
130
+ // When middle height changes (rotation / resize), keep sheet in proportion
131
+ const prevMiddleH = useRef(0)
132
+ useEffect(() => {
133
+ if (middleH <= 0) return
134
+ if (prevMiddleH.current === 0) {
135
+ prevMiddleH.current = middleH
136
+ return
137
+ }
138
+ if (prevMiddleH.current === middleH) return
139
+ const ratio = committedSheetH / prevMiddleH.current
140
+ const nextPx = Math.max(SHEET_HANDLE_PX, Math.min(middleH, ratio * middleH))
141
+ prevMiddleH.current = middleH
142
+ setCommittedSheetH(nextPx)
143
+ sheetRef.current?.snapTo(nextPx)
144
+ }, [middleH, committedSheetH])
145
+
146
+ const handleTabPress = useCallback(
147
+ (id: string) => {
148
+ if (middleH <= 0) return
149
+ const tab = sidebarTabs.find((t) => t.id === id)
150
+ if (!tab) return
151
+ const defaultPx = getDefaultSnap(tab) * middleH
152
+ if (id !== activePanel) {
153
+ setActivePanel(id)
154
+ sheetRef.current?.snapTo(defaultPx)
155
+ return
156
+ }
157
+ // Same tab tapped — toggle
158
+ const current = sheetRef.current?.getHeight() ?? committedSheetH
159
+ const expandedThreshold = Math.max(SHEET_HANDLE_PX, defaultPx * 0.5)
160
+ if (current > expandedThreshold) {
161
+ sheetRef.current?.snapTo(SHEET_HANDLE_PX)
162
+ } else {
163
+ sheetRef.current?.snapTo(defaultPx)
164
+ }
165
+ },
166
+ [sidebarTabs, activePanel, setActivePanel, middleH, committedSheetH],
167
+ )
168
+
169
+ const snapPointsPx = (() => {
170
+ if (middleH <= 0) return [SHEET_HANDLE_PX]
171
+ const intermediate = SNAP_RATIOS.map((r) => r * middleH)
172
+ return Array.from(new Set([SHEET_HANDLE_PX, ...intermediate, middleH])).sort((a, b) => a - b)
173
+ })()
174
+
175
+ // When the secondary panel sheet is open, it covers the tab bar + part of
176
+ // the middle area; translate its viewport height into middle-area units.
177
+ const panelPenetrationInMiddle = Math.max(0, panelSheetHeight - middleBottomFromViewport)
178
+ // The effective "sheet height" that the viewer sits above is the larger of
179
+ // the primary sidebar sheet and the secondary panel sheet's penetration.
180
+ const effectiveSheetH = Math.max(committedSheetH, panelPenetrationInMiddle)
181
+
182
+ // In capture mode the sheet and tab bar are hidden — the viewer should fill
183
+ // the entire middle area regardless of the stored sheet height.
184
+ // Otherwise, the viewer extends SHEET_OVERLAP_PX behind the sheet's rounded
185
+ // corners so the curve reveals viewer content underneath.
186
+ const baseViewerHeight = Math.max(0, middleH - effectiveSheetH)
187
+ const viewerHeight = isCaptureMode
188
+ ? middleH
189
+ : baseViewerHeight === 0
190
+ ? 0
191
+ : Math.min(middleH, baseViewerHeight + SHEET_OVERLAP_PX)
192
+
193
+ // While the panel sheet is open, collapse the primary sheet to its handle so
194
+ // it doesn't peek above. Remember the previous height and restore it on close.
195
+ const sheetHeightBeforePanel = useRef<number | null>(null)
196
+ useEffect(() => {
197
+ if (panelSheetHeight > 0) {
198
+ if (sheetHeightBeforePanel.current === null && committedSheetH > SHEET_HANDLE_PX) {
199
+ sheetHeightBeforePanel.current = committedSheetH
200
+ sheetRef.current?.snapTo(SHEET_HANDLE_PX)
201
+ }
202
+ } else if (sheetHeightBeforePanel.current !== null) {
203
+ const target = sheetHeightBeforePanel.current
204
+ sheetHeightBeforePanel.current = null
205
+ sheetRef.current?.snapTo(target)
206
+ }
207
+ }, [panelSheetHeight, committedSheetH])
208
+
209
+ return (
210
+ <div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
211
+ {navbarSlot}
212
+
213
+ <div
214
+ className="relative flex min-h-0 flex-1"
215
+ ref={middleRef}
216
+ style={{ backgroundColor: viewerBg }}
217
+ >
218
+ {/* Viewer column: sized by committed sheet height */}
219
+ <div className="absolute inset-x-0 top-0 overflow-hidden" style={{ height: viewerHeight }}>
220
+ <div className="relative h-full w-full">
221
+ {(viewerToolbarLeft || viewerToolbarRight) && !isCaptureMode && (
222
+ <div className="pointer-events-none absolute top-3 right-3 left-3 z-20 flex items-center justify-between gap-2">
223
+ <div className="pointer-events-auto flex items-center gap-2">
224
+ {viewerToolbarLeft}
225
+ </div>
226
+ <div className="pointer-events-auto flex items-center gap-2">
227
+ {viewerToolbarRight}
228
+ </div>
229
+ </div>
230
+ )}
231
+ <div className="relative h-full w-full overflow-hidden">{viewerContent}</div>
232
+ {overlays && (
233
+ <div
234
+ className="pointer-events-none absolute inset-0 z-30"
235
+ style={{ transform: 'translateZ(0)' }}
236
+ >
237
+ {overlays}
238
+ </div>
239
+ )}
240
+ </div>
241
+ </div>
242
+
243
+ {/* Bottom sheet: overlays the lower part of the middle area */}
244
+ {!isCaptureMode && sidebarTabs.length > 0 && (
245
+ <BottomSheet
246
+ initialHeightPx={SHEET_HANDLE_PX}
247
+ onCommit={setCommittedSheetH}
248
+ ref={sheetRef}
249
+ snapPointsPx={snapPointsPx}
250
+ >
251
+ <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
252
+ {renderTabContent(activePanel)}
253
+ {sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
254
+ </div>
255
+ </BottomSheet>
256
+ )}
257
+ </div>
258
+
259
+ {!isCaptureMode && sidebarTabs.length > 0 && (
260
+ <MobileTabBar activeTab={activePanel} onTabPress={handleTabPress} tabs={sidebarTabs} />
261
+ )}
262
+ </div>
263
+ )
264
+ }
@@ -1,9 +1,12 @@
1
1
  'use client'
2
2
 
3
3
  import { type ReactNode, useCallback, useEffect, useRef } from 'react'
4
+ import { useIsMobile } from '../../hooks/use-mobile'
4
5
  import useEditor from '../../store/use-editor'
6
+
5
7
  import { useSidebarStore } from '../ui/primitives/sidebar'
6
8
  import { type SidebarTab, TabBar } from '../ui/sidebar/tab-bar'
9
+ import { EditorLayoutMobile } from './editor-layout-mobile'
7
10
 
8
11
  const SIDEBAR_MIN_WIDTH = 300
9
12
  const SIDEBAR_MAX_WIDTH = 800
@@ -202,6 +205,23 @@ export function EditorLayoutV2({
202
205
  viewerContent,
203
206
  overlays,
204
207
  }: EditorLayoutV2Props) {
208
+ const isMobile = useIsMobile()
209
+
210
+ if (isMobile) {
211
+ return (
212
+ <EditorLayoutMobile
213
+ navbarSlot={navbarSlot}
214
+ overlays={overlays}
215
+ renderTabContent={renderTabContent}
216
+ sidebarOverlay={sidebarOverlay}
217
+ sidebarTabs={sidebarTabs}
218
+ viewerContent={viewerContent}
219
+ viewerToolbarLeft={viewerToolbarLeft}
220
+ viewerToolbarRight={viewerToolbarRight}
221
+ />
222
+ )
223
+ }
224
+
205
225
  return (
206
226
  <div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
207
227
  {/* Top navbar */}