@pascal-app/editor 0.4.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 (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. package/tsconfig.json +9 -0
@@ -0,0 +1,265 @@
1
+ 'use client'
2
+
3
+ import { useScene } from '@pascal-app/core'
4
+ import { ImageIcon, MessageSquare, X } from 'lucide-react'
5
+ import { useCallback, useRef, useState } from 'react'
6
+ import { Button } from './ui/primitives/button'
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from './ui/primitives/dialog'
14
+
15
+ const MAX_IMAGES = 5
16
+ const MAX_IMAGE_SIZE = 5 * 1024 * 1024
17
+
18
+ type ImagePreview = { file: File; url: string }
19
+
20
+ export function FeedbackDialog({
21
+ projectId: projectIdProp,
22
+ onSubmit,
23
+ }: {
24
+ projectId?: string
25
+ onSubmit?: (data: {
26
+ message: string
27
+ projectId?: string
28
+ sceneGraph: unknown
29
+ images: File[]
30
+ }) => Promise<{ success: boolean; error?: string }>
31
+ }) {
32
+ const projectId = projectIdProp
33
+
34
+ const [open, setOpen] = useState(false)
35
+ const [message, setMessage] = useState('')
36
+ const [images, setImages] = useState<ImagePreview[]>([])
37
+ const [isDragging, setIsDragging] = useState(false)
38
+ const [isSubmitting, setIsSubmitting] = useState(false)
39
+ const [error, setError] = useState<string | null>(null)
40
+ const [sent, setSent] = useState(false)
41
+ const fileInputRef = useRef<HTMLInputElement>(null)
42
+ const dragCounter = useRef(0)
43
+
44
+ const handleOpen = () => {
45
+ setOpen(true)
46
+ setSent(false)
47
+ setError(null)
48
+ setMessage('')
49
+ setImages([])
50
+ setIsDragging(false)
51
+ dragCounter.current = 0
52
+ }
53
+
54
+ const handleClose = () => {
55
+ if (isSubmitting) return
56
+ setOpen(false)
57
+ images.forEach((img) => {
58
+ URL.revokeObjectURL(img.url)
59
+ })
60
+ }
61
+
62
+ const addFiles = useCallback((files: FileList | File[]) => {
63
+ const incoming = Array.from(files).filter(
64
+ (f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_SIZE,
65
+ )
66
+ setImages((prev) => {
67
+ const remaining = MAX_IMAGES - prev.length
68
+ const added = incoming.slice(0, remaining).map((file) => ({
69
+ file,
70
+ url: URL.createObjectURL(file),
71
+ }))
72
+ return [...prev, ...added]
73
+ })
74
+ }, [])
75
+
76
+ const removeImage = (index: number) => {
77
+ setImages((prev) => {
78
+ const img = prev[index]
79
+ if (img) URL.revokeObjectURL(img.url)
80
+ return prev.filter((_, i) => i !== index)
81
+ })
82
+ }
83
+
84
+ // ── Drag handlers (on the entire dialog content) ──
85
+ const onDragEnter = (e: React.DragEvent) => {
86
+ e.preventDefault()
87
+ e.stopPropagation()
88
+ dragCounter.current++
89
+ if (e.dataTransfer.types.includes('Files')) {
90
+ setIsDragging(true)
91
+ }
92
+ }
93
+
94
+ const onDragLeave = (e: React.DragEvent) => {
95
+ e.preventDefault()
96
+ e.stopPropagation()
97
+ dragCounter.current--
98
+ if (dragCounter.current === 0) {
99
+ setIsDragging(false)
100
+ }
101
+ }
102
+
103
+ const onDragOver = (e: React.DragEvent) => {
104
+ e.preventDefault()
105
+ e.stopPropagation()
106
+ }
107
+
108
+ const onDrop = (e: React.DragEvent) => {
109
+ e.preventDefault()
110
+ e.stopPropagation()
111
+ dragCounter.current = 0
112
+ setIsDragging(false)
113
+ if (e.dataTransfer.files.length > 0) {
114
+ addFiles(e.dataTransfer.files)
115
+ }
116
+ }
117
+
118
+ const handleSubmit = async (e: React.FormEvent) => {
119
+ e.preventDefault()
120
+ setError(null)
121
+ setIsSubmitting(true)
122
+
123
+ try {
124
+ if (!onSubmit) return
125
+ const { nodes, rootNodeIds } = useScene.getState()
126
+ const sceneGraph = { nodes, rootNodeIds }
127
+ const result = await onSubmit({
128
+ message,
129
+ projectId,
130
+ sceneGraph,
131
+ images: images.map((img) => img.file),
132
+ })
133
+ if (result.success) {
134
+ setSent(true)
135
+ setTimeout(() => setOpen(false), 1500)
136
+ } else {
137
+ setError(result.error ?? 'Something went wrong')
138
+ }
139
+ } finally {
140
+ setIsSubmitting(false)
141
+ }
142
+ }
143
+
144
+ return (
145
+ <>
146
+ <button
147
+ className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
148
+ onClick={handleOpen}
149
+ >
150
+ <MessageSquare className="h-4 w-4" />
151
+ Feedback
152
+ </button>
153
+
154
+ <Dialog onOpenChange={handleClose} open={open}>
155
+ <DialogContent
156
+ className="sm:max-w-[460px]"
157
+ onDragEnter={onDragEnter}
158
+ onDragLeave={onDragLeave}
159
+ onDragOver={onDragOver}
160
+ onDrop={onDrop}
161
+ >
162
+ {/* Drag overlay — only visible when dragging files over the dialog */}
163
+ {isDragging && (
164
+ <div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-primary/50 border-dashed bg-primary/5 backdrop-blur-sm transition-all">
165
+ <div className="flex flex-col items-center gap-2 text-primary/70">
166
+ <ImageIcon className="h-8 w-8" />
167
+ <p className="font-medium text-sm">Drop images here</p>
168
+ </div>
169
+ </div>
170
+ )}
171
+
172
+ <DialogHeader>
173
+ <DialogTitle>Send Feedback</DialogTitle>
174
+ <DialogDescription>We&apos;d love to hear your thoughts</DialogDescription>
175
+ </DialogHeader>
176
+
177
+ {sent ? (
178
+ <p className="py-4 text-center text-muted-foreground text-sm">
179
+ Thanks for your feedback!
180
+ </p>
181
+ ) : (
182
+ <form className="space-y-4" onSubmit={handleSubmit}>
183
+ <div>
184
+ <label className="font-medium text-sm" htmlFor="feedback-message">
185
+ Your feedback
186
+ </label>
187
+ <textarea
188
+ autoFocus
189
+ className="mt-1 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
190
+ disabled={isSubmitting}
191
+ id="feedback-message"
192
+ onChange={(e) => setMessage(e.target.value)}
193
+ placeholder="Share your thoughts, suggestions, feature requests, or report issues..."
194
+ rows={5}
195
+ value={message}
196
+ />
197
+ </div>
198
+
199
+ {/* Image thumbnails */}
200
+ {images.length > 0 && (
201
+ <div className="flex flex-wrap gap-2">
202
+ {images.map((img, i) => (
203
+ <div
204
+ className="group relative h-14 w-14 overflow-hidden rounded-md border border-border"
205
+ key={img.url}
206
+ >
207
+ <img alt="" className="h-full w-full object-cover" src={img.url} />
208
+ <button
209
+ className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
210
+ onClick={() => removeImage(i)}
211
+ type="button"
212
+ >
213
+ <X className="h-4 w-4 text-white" />
214
+ </button>
215
+ </div>
216
+ ))}
217
+ </div>
218
+ )}
219
+
220
+ {error && <p className="text-destructive text-sm">{error}</p>}
221
+
222
+ <div className="flex items-center justify-between">
223
+ {/* Subtle attach button */}
224
+ <button
225
+ className="flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground disabled:opacity-40"
226
+ disabled={isSubmitting || images.length >= MAX_IMAGES}
227
+ onClick={() => fileInputRef.current?.click()}
228
+ type="button"
229
+ >
230
+ <ImageIcon className="h-3.5 w-3.5" />
231
+ {images.length > 0 ? `${images.length}/${MAX_IMAGES}` : 'Attach'}
232
+ </button>
233
+ <input
234
+ accept="image/*"
235
+ className="hidden"
236
+ multiple
237
+ onChange={(e) => {
238
+ if (e.target.files) addFiles(e.target.files)
239
+ e.target.value = ''
240
+ }}
241
+ ref={fileInputRef}
242
+ type="file"
243
+ />
244
+
245
+ <div className="flex gap-2">
246
+ <Button
247
+ disabled={isSubmitting}
248
+ onClick={handleClose}
249
+ type="button"
250
+ variant="outline"
251
+ >
252
+ Cancel
253
+ </Button>
254
+ <Button disabled={isSubmitting || !message.trim() || !onSubmit} type="submit">
255
+ {isSubmitting ? 'Sending...' : 'Send Feedback'}
256
+ </Button>
257
+ </div>
258
+ </div>
259
+ </form>
260
+ )}
261
+ </DialogContent>
262
+ </Dialog>
263
+ </>
264
+ )
265
+ }
@@ -0,0 +1,280 @@
1
+ 'use client'
2
+
3
+ import { Howl } from 'howler'
4
+ import { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
5
+ import { AnimatePresence, motion } from 'motion/react'
6
+ import { useCallback, useEffect, useRef, useState } from 'react'
7
+ import { Slider } from '../components/ui/slider'
8
+ import { cn } from '../lib/utils'
9
+ import useAudio from '../store/use-audio'
10
+
11
+ const PLAYLIST = [
12
+ {
13
+ title: 'Ballroom in Miniature',
14
+ file: '/audios/radios/classic/Ballroom in Miniature.mp3',
15
+ },
16
+ {
17
+ title: 'Blueprints in Springtime',
18
+ file: '/audios/radios/classic/Blueprints in Springtime.mp3',
19
+ },
20
+ {
21
+ title: 'Clockwork Tea Party',
22
+ file: '/audios/radios/classic/Clockwork Tea Party.mp3',
23
+ },
24
+ {
25
+ title: 'Clockwork Tea Party (Alternate)',
26
+ file: '/audios/radios/classic/Clockwork Tea Party (Alternate).mp3',
27
+ },
28
+ {
29
+ title: 'Clockwork Teacups',
30
+ file: '/audios/radios/classic/Clockwork Teacups.mp3',
31
+ },
32
+ {
33
+ title: 'Evening in the Parlor',
34
+ file: '/audios/radios/classic/Evening in the Parlor.mp3',
35
+ },
36
+ {
37
+ title: 'Glass Atrium',
38
+ file: '/audios/radios/classic/Glass Atrium.mp3',
39
+ },
40
+ {
41
+ title: 'Moonlight On The Drafting Table',
42
+ file: '/audios/radios/classic/Moonlight On The Drafting Table.mp3',
43
+ },
44
+ {
45
+ title: 'Sunlit Garden Reverie',
46
+ file: '/audios/radios/classic/Sunlit Garden Reverie.mp3',
47
+ },
48
+ {
49
+ title: 'Sunlit Waltz in Pastel Hues',
50
+ file: '/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3',
51
+ },
52
+ ]
53
+
54
+ // Shuffle array helper
55
+ function shuffleArray<T>(array: T[]): T[] {
56
+ const shuffled = [...array]
57
+ for (let i = shuffled.length - 1; i > 0; i--) {
58
+ const j = Math.floor(Math.random() * (i + 1))
59
+ ;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
60
+ }
61
+ return shuffled
62
+ }
63
+
64
+ export function PascalRadio() {
65
+ const [shuffledPlaylist] = useState(() => shuffleArray(PLAYLIST))
66
+ const [currentTrackIndex, setCurrentTrackIndex] = useState(0)
67
+ const { masterVolume, radioVolume, muted, isRadioPlaying, setRadioPlaying } = useAudio()
68
+ const soundRef = useRef<Howl | null>(null)
69
+ const [isOpen, setIsOpen] = useState(false)
70
+ const containerRef = useRef<HTMLDivElement>(null)
71
+
72
+ const currentTrack = shuffledPlaylist[currentTrackIndex]!
73
+
74
+ // Calculate effective volume (masterVolume * radioVolume, both are 0-100)
75
+ const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
76
+
77
+ // Keep a ref so the track-init effect can read current volume/muted/isRadioPlaying
78
+ // without those values being part of its dependency array (which would restart the song).
79
+ const effectiveVolumeRef = useRef(effectiveVolume)
80
+ const mutedRef = useRef(muted)
81
+ const isPlayingRef = useRef(isRadioPlaying)
82
+ effectiveVolumeRef.current = effectiveVolume
83
+ mutedRef.current = muted
84
+ isPlayingRef.current = isRadioPlaying
85
+
86
+ const handleNext = useCallback(() => {
87
+ setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
88
+ }, [shuffledPlaylist.length])
89
+
90
+ const handlePrevious = useCallback(() => {
91
+ setCurrentTrackIndex((prev) => (prev - 1 + shuffledPlaylist.length) % shuffledPlaylist.length)
92
+ }, [shuffledPlaylist.length])
93
+
94
+ // Initialize Howler only when the track changes — not on volume/mute/play-state changes.
95
+ // Volume and mute are handled by the separate effect below.
96
+ useEffect(() => {
97
+ if (soundRef.current) {
98
+ soundRef.current.unload()
99
+ }
100
+
101
+ const wasPlaying = isPlayingRef.current
102
+
103
+ soundRef.current = new Howl({
104
+ src: [currentTrack.file],
105
+ volume: mutedRef.current ? 0 : effectiveVolumeRef.current,
106
+ onend: handleNext,
107
+ })
108
+
109
+ if (wasPlaying && !mutedRef.current) {
110
+ soundRef.current?.play()
111
+ }
112
+
113
+ return () => {
114
+ soundRef.current?.unload()
115
+ }
116
+ }, [handleNext, currentTrack.file])
117
+
118
+ // Update volume when settings change
119
+ useEffect(() => {
120
+ if (soundRef.current) {
121
+ soundRef.current.volume(muted ? 0 : effectiveVolume)
122
+
123
+ // Pause if muted, resume if unmuted and was playing
124
+ if (muted && isRadioPlaying) {
125
+ soundRef.current.pause()
126
+ } else if (!muted && isRadioPlaying && !soundRef.current.playing()) {
127
+ soundRef.current.play()
128
+ } else if (!isRadioPlaying && soundRef.current.playing()) {
129
+ soundRef.current.pause()
130
+ }
131
+ }
132
+ }, [effectiveVolume, muted, isRadioPlaying])
133
+
134
+ const handlePlayPause = () => {
135
+ if (!soundRef.current || muted) return
136
+
137
+ if (isRadioPlaying) {
138
+ soundRef.current.pause()
139
+ } else {
140
+ soundRef.current.play()
141
+ }
142
+ setRadioPlaying(!isRadioPlaying)
143
+ }
144
+
145
+ const handleVolumeChange = (value: number[]) => {
146
+ useAudio.setState({ radioVolume: value[0] })
147
+ }
148
+
149
+ // Handle click outside to close
150
+ useEffect(() => {
151
+ function handleClickOutside(event: MouseEvent) {
152
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
153
+ setIsOpen(false)
154
+ }
155
+ }
156
+ if (isOpen) {
157
+ document.addEventListener('mousedown', handleClickOutside)
158
+ }
159
+ return () => {
160
+ document.removeEventListener('mousedown', handleClickOutside)
161
+ }
162
+ }, [isOpen])
163
+
164
+ return (
165
+ <motion.div
166
+ className={cn(
167
+ 'flex flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md',
168
+ !isOpen && 'cursor-pointer transition-colors hover:bg-accent/90',
169
+ )}
170
+ layout
171
+ onClick={() => {
172
+ if (!isOpen) setIsOpen(true)
173
+ }}
174
+ ref={containerRef}
175
+ transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
176
+ >
177
+ <div className="flex items-center justify-between gap-2 px-3 py-2 font-medium text-sm">
178
+ <div className="flex items-center gap-2">
179
+ <Disc3 className={cn('h-4 w-4 shrink-0', isRadioPlaying && 'animate-spin')} />
180
+ <span className="hidden whitespace-nowrap sm:inline">Radio Pascal</span>
181
+ </div>
182
+ <div className="flex items-center gap-2">
183
+ <div
184
+ aria-label={isRadioPlaying ? 'Pause' : 'Play'}
185
+ className="cursor-pointer rounded-sm bg-accent/30 p-1 transition-all hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
186
+ onClick={(e) => {
187
+ e.stopPropagation()
188
+ handlePlayPause()
189
+ }}
190
+ onKeyDown={(e) => {
191
+ if (e.key === 'Enter' || e.key === ' ') {
192
+ e.preventDefault()
193
+ e.stopPropagation()
194
+ handlePlayPause()
195
+ }
196
+ }}
197
+ role="button"
198
+ tabIndex={0}
199
+ >
200
+ {isRadioPlaying ? (
201
+ <Volume2 className="h-3.5 w-3.5" />
202
+ ) : (
203
+ <VolumeX className="h-3.5 w-3.5" />
204
+ )}
205
+ </div>
206
+ <button
207
+ aria-label="Radio Settings"
208
+ className={cn(
209
+ 'cursor-pointer rounded-sm p-1 transition-all hover:bg-accent hover:text-accent-foreground',
210
+ isOpen && 'bg-accent text-accent-foreground',
211
+ )}
212
+ onClick={(e) => {
213
+ e.stopPropagation()
214
+ setIsOpen(!isOpen)
215
+ }}
216
+ >
217
+ <Settings2 className="h-3.5 w-3.5" />
218
+ </button>
219
+ </div>
220
+ </div>
221
+
222
+ <AnimatePresence>
223
+ {isOpen && (
224
+ <motion.div
225
+ animate={{ opacity: 1, height: 'auto' }}
226
+ exit={{ opacity: 0, height: 0 }}
227
+ initial={{ opacity: 0, height: 0 }}
228
+ transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
229
+ >
230
+ <div className="w-[16rem] space-y-3 px-3 pb-3">
231
+ <div className="mb-3 h-px w-full bg-border/50" />
232
+ {/* Current song info with prev/next */}
233
+ <div>
234
+ <p className="mb-2 text-muted-foreground text-xs">Now Playing</p>
235
+ <div className="flex items-center justify-between gap-2">
236
+ <button
237
+ aria-label="Previous"
238
+ className="shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent"
239
+ onClick={handlePrevious}
240
+ >
241
+ <SkipBack className="h-4 w-4" />
242
+ </button>
243
+ <p
244
+ className="flex-1 truncate text-center font-medium text-sm"
245
+ title={currentTrack.title}
246
+ >
247
+ {currentTrack.title}
248
+ </p>
249
+ <button
250
+ aria-label="Next"
251
+ className="shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent"
252
+ onClick={handleNext}
253
+ >
254
+ <SkipForward className="h-4 w-4" />
255
+ </button>
256
+ </div>
257
+ </div>
258
+
259
+ {/* Volume control */}
260
+ <div className="flex items-center gap-2">
261
+ <Volume2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
262
+ <Slider
263
+ aria-label="Radio Volume"
264
+ className="flex-1"
265
+ max={100}
266
+ onValueChange={handleVolumeChange}
267
+ step={1}
268
+ value={[radioVolume]}
269
+ />
270
+ <span className="w-8 shrink-0 text-right text-muted-foreground text-xs">
271
+ {radioVolume}%
272
+ </span>
273
+ </div>
274
+ </div>
275
+ </motion.div>
276
+ )}
277
+ </AnimatePresence>
278
+ </motion.div>
279
+ )
280
+ }
@@ -0,0 +1,16 @@
1
+ 'use client'
2
+
3
+ import { Eye } from 'lucide-react'
4
+ import useEditor from '../store/use-editor'
5
+
6
+ export function PreviewButton() {
7
+ return (
8
+ <button
9
+ className="flex cursor-pointer items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
10
+ onClick={() => useEditor.getState().setPreviewMode(true)}
11
+ >
12
+ <Eye className="h-4 w-4 shrink-0" />
13
+ <span className="hidden whitespace-nowrap sm:inline">Preview</span>
14
+ </button>
15
+ )
16
+ }
@@ -0,0 +1,77 @@
1
+ import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import { useEffect } from 'react'
4
+ import useEditor from '../../../store/use-editor'
5
+
6
+ export const CeilingSystem = () => {
7
+ const tool = useEditor((state) => state.tool)
8
+ const selectedItem = useEditor((state) => state.selectedItem)
9
+ const movingNode = useEditor((state) => state.movingNode)
10
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
11
+ const activeLevelId = useViewer((state) => state.selection.levelId)
12
+
13
+ useEffect(() => {
14
+ const nodes = useScene.getState().nodes
15
+
16
+ const levelsToShowCeilings = new Set<string>()
17
+
18
+ const isCeilingToolActive =
19
+ tool === 'ceiling' ||
20
+ selectedItem?.attachTo === 'ceiling' ||
21
+ (movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling')
22
+
23
+ if (isCeilingToolActive && activeLevelId) {
24
+ levelsToShowCeilings.add(activeLevelId)
25
+ }
26
+
27
+ for (const id of selectedIds) {
28
+ let currentId: string | null = id
29
+ let isCeilingRelated = false
30
+ let levelId: string | null = null
31
+
32
+ while (currentId && nodes[currentId as AnyNodeId]) {
33
+ const node = nodes[currentId as AnyNodeId]
34
+ if (node?.type === 'ceiling') {
35
+ isCeilingRelated = true
36
+ }
37
+ if (node?.type === 'level') {
38
+ levelId = node.id
39
+ break
40
+ }
41
+ currentId = node?.parentId as string | null
42
+ }
43
+
44
+ if (isCeilingRelated && levelId) {
45
+ levelsToShowCeilings.add(levelId)
46
+ }
47
+ }
48
+
49
+ const ceilings = sceneRegistry.byType.ceiling
50
+ ceilings.forEach((ceiling) => {
51
+ const mesh = sceneRegistry.nodes.get(ceiling)
52
+ if (mesh) {
53
+ const ceilingGrid = mesh.getObjectByName('ceiling-grid')
54
+ if (ceilingGrid) {
55
+ let belongsToVisibleLevel = false
56
+ let currentId: string | null = ceiling
57
+
58
+ while (currentId && nodes[currentId as AnyNodeId]) {
59
+ const node = nodes[currentId as AnyNodeId]
60
+ if (node && levelsToShowCeilings.has(node.id)) {
61
+ belongsToVisibleLevel = true
62
+ break
63
+ }
64
+ currentId = node?.parentId as string | null
65
+ }
66
+
67
+ const shouldShowGrid =
68
+ belongsToVisibleLevel || (levelsToShowCeilings.size === 0 && isCeilingToolActive)
69
+
70
+ ceilingGrid.visible = shouldShowGrid
71
+ ceilingGrid.scale.setScalar(shouldShowGrid ? 1 : 0.0) // Scale down to zero to prevent event interference when grid is hidden
72
+ }
73
+ }
74
+ })
75
+ }, [tool, selectedItem, movingNode, selectedIds, activeLevelId])
76
+ return null
77
+ }