@pascal-app/editor 0.6.0 → 0.8.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 (157) hide show
  1. package/package.json +13 -9
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +74 -5
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +24 -3
  6. package/src/components/editor/first-person/build-collider-world.ts +363 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -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 +9861 -3297
  12. package/src/components/editor/index.tsx +295 -32
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  15. package/src/components/editor/thumbnail-generator.tsx +56 -68
  16. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  17. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  18. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  19. package/src/components/editor/wall-measurement-label.tsx +267 -36
  20. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  21. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  22. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  23. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
  24. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  25. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
  26. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  27. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  28. package/src/components/editor-2d/svg-paths.ts +119 -0
  29. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  30. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  31. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  32. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  33. package/src/components/systems/zone/zone-system.tsx +0 -0
  34. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  35. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  36. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  37. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  38. package/src/components/tools/column/column-tool.tsx +97 -0
  39. package/src/components/tools/column/move-column-tool.tsx +105 -0
  40. package/src/components/tools/door/door-tool.tsx +7 -0
  41. package/src/components/tools/door/move-door-tool.tsx +28 -8
  42. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  43. package/src/components/tools/fence/fence-drafting.ts +10 -3
  44. package/src/components/tools/fence/fence-tool.tsx +160 -4
  45. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
  46. package/src/components/tools/fence/move-fence-tool.tsx +111 -40
  47. package/src/components/tools/item/move-tool.tsx +7 -1
  48. package/src/components/tools/item/placement-math.ts +32 -5
  49. package/src/components/tools/item/placement-strategies.ts +110 -31
  50. package/src/components/tools/item/placement-types.ts +7 -0
  51. package/src/components/tools/item/use-draft-node.ts +1 -0
  52. package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
  53. package/src/components/tools/roof/move-roof-tool.tsx +29 -17
  54. package/src/components/tools/select/box-select-tool.tsx +12 -17
  55. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  56. package/src/components/tools/shared/segment-angle.ts +156 -0
  57. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  58. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  59. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  60. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  61. package/src/components/tools/tool-manager.tsx +20 -5
  62. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
  64. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  65. package/src/components/tools/wall/wall-drafting.ts +18 -9
  66. package/src/components/tools/wall/wall-tool.tsx +136 -4
  67. package/src/components/tools/window/move-window-tool.tsx +18 -0
  68. package/src/components/tools/window/window-tool.tsx +5 -0
  69. package/src/components/tools/zone/zone-tool.tsx +20 -5
  70. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  71. package/src/components/ui/action-menu/control-modes.tsx +34 -1
  72. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  73. package/src/components/ui/action-menu/index.tsx +98 -59
  74. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  75. package/src/components/ui/action-menu/view-toggles.tsx +418 -41
  76. package/src/components/ui/command-palette/editor-commands.tsx +24 -5
  77. package/src/components/ui/command-palette/index.tsx +4 -255
  78. package/src/components/ui/controls/material-picker.tsx +154 -164
  79. package/src/components/ui/controls/slider-control.tsx +66 -18
  80. package/src/components/ui/floating-level-selector.tsx +286 -55
  81. package/src/components/ui/helpers/helper-manager.tsx +10 -0
  82. package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
  83. package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
  84. package/src/components/ui/level-duplicate-dialog.tsx +113 -0
  85. package/src/components/ui/panels/ceiling-panel.tsx +3 -28
  86. package/src/components/ui/panels/column-panel.tsx +759 -0
  87. package/src/components/ui/panels/door-panel.tsx +989 -290
  88. package/src/components/ui/panels/fence-panel.tsx +2 -49
  89. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  90. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  91. package/src/components/ui/panels/node-display.ts +39 -0
  92. package/src/components/ui/panels/paint-panel.tsx +163 -0
  93. package/src/components/ui/panels/panel-manager.tsx +208 -28
  94. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  95. package/src/components/ui/panels/reference-panel.tsx +253 -5
  96. package/src/components/ui/panels/roof-panel.tsx +13 -64
  97. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  98. package/src/components/ui/panels/slab-panel.tsx +4 -30
  99. package/src/components/ui/panels/spawn-panel.tsx +161 -0
  100. package/src/components/ui/panels/stair-panel.tsx +20 -74
  101. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  102. package/src/components/ui/panels/wall-panel.tsx +10 -8
  103. package/src/components/ui/panels/window-panel.tsx +668 -139
  104. package/src/components/ui/primitives/number-input.tsx +1 -1
  105. package/src/components/ui/primitives/sidebar.tsx +0 -0
  106. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  107. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  108. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  109. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  110. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  112. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  114. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  115. package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
  116. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  117. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  118. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
  119. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
  120. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
  121. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/slider.tsx +1 -1
  124. package/src/components/viewer-overlay.tsx +0 -0
  125. package/src/components/viewer-zone-system.tsx +0 -0
  126. package/src/hooks/use-auto-frame.ts +45 -0
  127. package/src/hooks/use-auto-save.ts +14 -0
  128. package/src/hooks/use-keyboard.ts +74 -7
  129. package/src/hooks/use-mobile.ts +12 -12
  130. package/src/index.tsx +8 -1
  131. package/src/lib/door-interaction.ts +88 -0
  132. package/src/lib/floorplan/geometry.ts +263 -0
  133. package/src/lib/floorplan/index.ts +38 -0
  134. package/src/lib/floorplan/items.ts +179 -0
  135. package/src/lib/floorplan/selection-tool.ts +231 -0
  136. package/src/lib/floorplan/stairs.ts +478 -0
  137. package/src/lib/floorplan/types.ts +57 -0
  138. package/src/lib/floorplan/walls.ts +23 -0
  139. package/src/lib/guide-events.ts +10 -0
  140. package/src/lib/level-duplication.test.ts +70 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/scene.ts +0 -0
  148. package/src/lib/sfx-bus.ts +2 -0
  149. package/src/lib/sfx-player.ts +5 -5
  150. package/src/lib/stair-duplication.ts +126 -0
  151. package/src/lib/window-interaction.ts +86 -0
  152. package/src/store/use-editor.tsx +186 -62
  153. package/tsconfig.json +2 -1
  154. package/src/components/feedback-dialog.tsx +0 -265
  155. package/src/components/pascal-radio.tsx +0 -280
  156. package/src/components/preview-button.tsx +0 -16
  157. package/src/components/ui/viewer-toolbar.tsx +0 -395
@@ -144,7 +144,7 @@ export function NumberInput({
144
144
  }}
145
145
  />
146
146
  <div
147
- className={`relative z-10 flex items-center overflow-hidden rounded-lg border shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] transition-all focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${isDragging ? 'border-neutral-300 bg-transparent ring-1 ring-neutral-200/60 dark:border-border dark:ring-border/50' : 'border-neutral-200/60 bg-white hover:border-neutral-300 dark:border-border/50 dark:bg-accent/30 dark:hover:border-border/80'}`}
147
+ className={`relative z-10 flex items-center overflow-hidden rounded-lg border shadow-elevation-0 transition-all focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${isDragging ? 'border-neutral-300 bg-transparent ring-1 ring-neutral-200/60 dark:border-border dark:ring-border/50' : 'border-neutral-200/60 bg-white hover:border-neutral-300 dark:border-border/50 dark:bg-accent/30 dark:hover:border-border/80'}`}
148
148
  >
149
149
  <div
150
150
  className={`z-10 select-none truncate py-1.5 pr-1 pl-2 font-barlow font-medium text-muted-foreground text-xs ${
File without changes
File without changes
File without changes
@@ -0,0 +1,46 @@
1
+ 'use client'
2
+
3
+ import { cn } from './../../../lib/utils'
4
+ import type { SidebarTab } from './tab-bar'
5
+
6
+ interface MobileTabBarProps {
7
+ tabs: SidebarTab[]
8
+ activeTab: string
9
+ onTabPress: (id: string) => void
10
+ }
11
+
12
+ export function MobileTabBar({ tabs, activeTab, onTabPress }: MobileTabBarProps) {
13
+ return (
14
+ <div
15
+ className="z-50 flex h-14 shrink-0 border-border/50 border-t bg-sidebar text-sidebar-foreground"
16
+ style={{
17
+ // Cap the safe-area inset — iOS Chrome can report its bottom UI bar
18
+ // (50–100px) as part of the safe area which would balloon the tab bar.
19
+ // 34px matches the iPhone home-indicator height (the typical max).
20
+ paddingBottom: 'min(env(safe-area-inset-bottom, 0px), 34px)',
21
+ }}
22
+ >
23
+ {tabs.map((tab) => {
24
+ const isActive = activeTab === tab.id
25
+ return (
26
+ <button
27
+ className={cn(
28
+ 'flex flex-1 flex-col items-center justify-center gap-0.5 text-xs transition-colors',
29
+ isActive ? 'text-foreground' : 'text-muted-foreground',
30
+ )}
31
+ key={tab.id}
32
+ onClick={() => onTabPress(tab.id)}
33
+ type="button"
34
+ >
35
+ {tab.mobileIcon ? (
36
+ <span className={cn('flex h-5 w-5 items-center justify-center')}>
37
+ {tab.mobileIcon}
38
+ </span>
39
+ ) : null}
40
+ <span className="font-medium">{tab.label}</span>
41
+ </button>
42
+ )
43
+ })}
44
+ </div>
45
+ )
46
+ }
@@ -0,0 +1,330 @@
1
+ 'use client'
2
+
3
+ import type { AssetInput } from '@pascal-app/core'
4
+ import NextImage from 'next/image'
5
+ import { useEffect, useState } from 'react'
6
+ import { cn } from '../../../../../lib/utils'
7
+ import type { CatalogCategory } from '../../../../../store/use-editor'
8
+ import useEditor from '../../../../../store/use-editor'
9
+ import { furnishTools } from '../../../action-menu/furnish-tools'
10
+ import { CATALOG_ITEMS } from '../../../item-catalog/catalog-items'
11
+ import { ItemCatalog } from '../../../item-catalog/item-catalog'
12
+
13
+ const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
14
+
15
+ export function ItemsPanel({
16
+ items,
17
+ onSearchChange,
18
+ searchResults,
19
+ leadingTile,
20
+ emptyState,
21
+ }: {
22
+ items?: AssetInput[]
23
+ /** Called when the search query changes (community edition uses this for server-side search) */
24
+ onSearchChange?: (query: string) => void
25
+ /** When non-null and search is active, these results bypass local filtering (server search results) */
26
+ searchResults?: AssetInput[] | null
27
+ /**
28
+ * Optional node rendered as the first grid cell, always visible. Used by the
29
+ * community edition to inject a "+ Generate with AI" tile.
30
+ */
31
+ leadingTile?: React.ReactNode
32
+ /**
33
+ * Optional node rendered when the grid has no items to show (empty category
34
+ * or no search results). Replaces the default "No results" message.
35
+ */
36
+ emptyState?: React.ReactNode
37
+ }) {
38
+ const mode = useEditor((s) => s.mode)
39
+ const catalogCategory = useEditor((s) => s.catalogCategory)
40
+ const setMode = useEditor((s) => s.setMode)
41
+ const setTool = useEditor((s) => s.setTool)
42
+ const setCatalogCategory = useEditor((s) => s.setCatalogCategory)
43
+
44
+ const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null)
45
+ const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
46
+ // Library / Community / Mine. Default to Library so first-time users see
47
+ // the curated catalog rather than every uploaded item; clicking the chip
48
+ // again clears the filter (`null` = show everything).
49
+ const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>('library')
50
+ const [search, setSearch] = useState('')
51
+ const isServerSearch = onSearchChange !== undefined
52
+ // True when server search is active but results haven't come back yet
53
+ const isSearchPending = isServerSearch && search.length > 0 && searchResults === null
54
+
55
+ // Auto-select the first category when the panel mounts without one
56
+ useEffect(() => {
57
+ if (!(catalogCategory && furnishTools.some((c) => c.catalogCategory === catalogCategory))) {
58
+ setCatalogCategory(furnishTools[0]!.catalogCategory)
59
+ }
60
+ }, [catalogCategory, setCatalogCategory])
61
+
62
+ const activeCategory =
63
+ furnishTools.find((c) => c.catalogCategory === catalogCategory) ?? furnishTools[0]!
64
+
65
+ function selectCategory(categoryId: CatalogCategory) {
66
+ setCatalogCategory(categoryId)
67
+ setTool('item')
68
+ setActivePlacementTag(null)
69
+ setActiveFunctionalTag(null)
70
+ setSearch('')
71
+ if (mode !== 'build') setMode('build')
72
+ }
73
+
74
+ // Compute tags for the current category (for filter chips)
75
+ const baseItems = items ?? CATALOG_ITEMS
76
+ // Apply the Library/Community/Mine filter before any category/tag work.
77
+ // Items that don't carry a source field (e.g. seeded built-in catalog
78
+ // entries from `CATALOG_ITEMS`) fall under "library".
79
+ //
80
+ // Community is broader than just other users' uploads: my own *published*
81
+ // items show up there too so I can preview my catalog the way other users
82
+ // see it. My drafts only appear under Mine.
83
+ const matchesSource = (item: AssetInput) => {
84
+ if (!activeSource) return true
85
+ const itemSource = item.source ?? 'library'
86
+ if (activeSource === 'mine') return itemSource === 'mine'
87
+ if (activeSource === 'library') return itemSource === 'library'
88
+ if (activeSource === 'community') {
89
+ if (itemSource === 'community') return true
90
+ if (itemSource === 'mine') return !item.isDraft
91
+ return false
92
+ }
93
+ return true
94
+ }
95
+ const sourceItems = baseItems.filter(matchesSource)
96
+ const categoryItems = sourceItems.filter(
97
+ (item) => item.category === activeCategory.catalogCategory,
98
+ )
99
+
100
+ // The three source chips are always shown so users can discover the
101
+ // filter even before they own any items. Selecting "Mine" with no
102
+ // matching items falls through to the empty/no-results state.
103
+ const sourceChips: Array<{ id: AssetInput['source']; label: string }> = [
104
+ { id: 'library', label: 'Library' },
105
+ { id: 'community', label: 'Community' },
106
+ { id: 'mine', label: 'Mine' },
107
+ ]
108
+ const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
109
+ const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
110
+ const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))
111
+ const hasFilters = allTags.length > 1
112
+
113
+ const placementCount = (tag: string | null) =>
114
+ categoryItems.filter((item) => {
115
+ const tags = item.tags ?? []
116
+ if (tag !== null && !tags.includes(tag)) return false
117
+ if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
118
+ return true
119
+ }).length
120
+
121
+ const functionalCount = (tag: string) =>
122
+ categoryItems.filter((item) => {
123
+ const tags = item.tags ?? []
124
+ if (!tags.includes(tag)) return false
125
+ if (activePlacementTag && !tags.includes(activePlacementTag)) return false
126
+ return true
127
+ }).length
128
+
129
+ return (
130
+ <div className="flex h-full flex-col">
131
+ {/* Category tabs */}
132
+ <div className="flex shrink-0 gap-1 overflow-x-auto border-border/70 border-b p-2">
133
+ {furnishTools.map((cat) => {
134
+ const isActive = activeCategory.catalogCategory === cat.catalogCategory
135
+ return (
136
+ <button
137
+ className={cn(
138
+ 'flex shrink-0 flex-col items-center gap-1 rounded-xl px-3 py-2 transition-colors',
139
+ isActive
140
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground'
141
+ : 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground',
142
+ )}
143
+ key={cat.catalogCategory}
144
+ onClick={() => selectCategory(cat.catalogCategory)}
145
+ type="button"
146
+ >
147
+ <NextImage
148
+ alt={cat.label}
149
+ className={cn('size-7 object-contain', !isActive && 'opacity-60 grayscale')}
150
+ height={28}
151
+ src={cat.iconSrc}
152
+ width={28}
153
+ />
154
+ <span className="font-medium text-[10px] leading-none">{cat.label}</span>
155
+ </button>
156
+ )
157
+ })}
158
+ </div>
159
+
160
+ {/* Search + filters (non-scrollable) */}
161
+ <div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
162
+ <div className="flex items-center gap-1.5">
163
+ {/* Search and source filter take 50/50 of the row. `min-w-0` on
164
+ both sides lets each half shrink to fit when the panel narrows. */}
165
+ <input
166
+ className="w-1/2 min-w-0 shrink-0 rounded-lg bg-muted px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-none"
167
+ onChange={(e) => {
168
+ setSearch(e.target.value)
169
+ onSearchChange?.(e.target.value)
170
+ }}
171
+ placeholder="Search..."
172
+ type="text"
173
+ value={search}
174
+ />
175
+ {sourceChips.length > 0 && (
176
+ <div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
177
+ {sourceChips.map((chip) => {
178
+ const isActive = activeSource === chip.id
179
+ return (
180
+ <button
181
+ className={cn(
182
+ 'min-w-0 flex-1 truncate rounded-md px-1 py-1 text-center font-medium text-[10px] transition-colors',
183
+ isActive
184
+ ? 'bg-background text-foreground shadow-sm'
185
+ : 'text-muted-foreground hover:text-foreground',
186
+ )}
187
+ key={chip.id}
188
+ onClick={() => setActiveSource(isActive ? null : chip.id)}
189
+ type="button"
190
+ >
191
+ {chip.label}
192
+ </button>
193
+ )
194
+ })}
195
+ </div>
196
+ )}
197
+ </div>
198
+
199
+ {hasFilters && !search && !isServerSearch && (
200
+ <div className="flex flex-col gap-1.5">
201
+ {placementTags.length > 0 && (
202
+ <div className="flex flex-wrap gap-1">
203
+ <button
204
+ className={cn(
205
+ 'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
206
+ activePlacementTag === null
207
+ ? 'bg-blue-500 text-white'
208
+ : 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
209
+ )}
210
+ onClick={() => setActivePlacementTag(null)}
211
+ type="button"
212
+ >
213
+ All
214
+ </button>
215
+ {placementTags.map((tag) => {
216
+ const count = placementCount(tag)
217
+ const isActive = activePlacementTag === tag
218
+ const isEmpty = count === 0 && !isActive
219
+ return (
220
+ <button
221
+ className={cn(
222
+ 'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
223
+ isActive
224
+ ? 'bg-blue-500 text-white'
225
+ : isEmpty
226
+ ? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
227
+ : 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
228
+ )}
229
+ disabled={isEmpty}
230
+ key={tag}
231
+ onClick={() => setActivePlacementTag(isActive ? null : tag)}
232
+ type="button"
233
+ >
234
+ {tag}
235
+ <span
236
+ className={cn(
237
+ 'text-[10px]',
238
+ isActive
239
+ ? 'text-blue-200'
240
+ : isEmpty
241
+ ? 'text-zinc-600'
242
+ : 'text-blue-500/70',
243
+ )}
244
+ >
245
+ {count}
246
+ </span>
247
+ </button>
248
+ )
249
+ })}
250
+ </div>
251
+ )}
252
+
253
+ {functionalTags.length > 0 && (
254
+ <div className="flex flex-wrap gap-1">
255
+ {functionalTags.map((tag) => {
256
+ const count = functionalCount(tag)
257
+ const isActive = activeFunctionalTag === tag
258
+ const isEmpty = count === 0 && !isActive
259
+ return (
260
+ <button
261
+ className={cn(
262
+ 'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
263
+ isActive
264
+ ? 'bg-violet-500 text-white'
265
+ : isEmpty
266
+ ? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
267
+ : 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
268
+ )}
269
+ disabled={isEmpty}
270
+ key={tag}
271
+ onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
272
+ type="button"
273
+ >
274
+ {tag}
275
+ <span
276
+ className={cn(
277
+ 'text-[10px]',
278
+ isActive
279
+ ? 'text-violet-200'
280
+ : isEmpty
281
+ ? 'text-zinc-600'
282
+ : 'text-zinc-500/70',
283
+ )}
284
+ >
285
+ {count}
286
+ </span>
287
+ </button>
288
+ )
289
+ })}
290
+ </div>
291
+ )}
292
+ </div>
293
+ )}
294
+ </div>
295
+
296
+ {/* Item grid */}
297
+ <div className="min-h-0 flex-1 overflow-y-auto p-3">
298
+ {isSearchPending ? (
299
+ <div className="flex h-full items-center justify-center">
300
+ <div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground" />
301
+ </div>
302
+ ) : isServerSearch && search && searchResults?.length === 0 ? (
303
+ (emptyState ?? (
304
+ <div className="flex h-full items-center justify-center text-muted-foreground text-xs">
305
+ No results for &ldquo;{search}&rdquo;
306
+ </div>
307
+ ))
308
+ ) : (
309
+ <ItemCatalog
310
+ activeFunctionalTag={isServerSearch ? null : activeFunctionalTag}
311
+ activePlacementTag={isServerSearch ? null : activePlacementTag}
312
+ category={activeCategory.catalogCategory}
313
+ emptyState={emptyState}
314
+ items={activeSource && items ? items.filter(matchesSource) : items}
315
+ key={activeCategory.catalogCategory}
316
+ leadingTile={leadingTile}
317
+ overrideItems={
318
+ isServerSearch && search
319
+ ? activeSource && searchResults
320
+ ? searchResults.filter(matchesSource)
321
+ : (searchResults ?? undefined)
322
+ : undefined
323
+ }
324
+ search={isServerSearch ? '' : search}
325
+ />
326
+ )}
327
+ </div>
328
+ </div>
329
+ )
330
+ }
@@ -88,8 +88,8 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
88
88
  {
89
89
  title: 'Item Placement',
90
90
  shortcuts: [
91
- { keys: ['R'], action: 'Rotate item clockwise by 90 degrees' },
92
- { keys: ['T'], action: 'Rotate item counter-clockwise by 90 degrees' },
91
+ { keys: ['R'], action: 'Rotate item clockwise, or toggle selected door open/closed' },
92
+ { keys: ['T'], action: 'Rotate item counter-clockwise, or close selected door' },
93
93
  {
94
94
  keys: ['Shift'],
95
95
  action: 'Temporarily bypass placement validation constraints',
@@ -1,4 +1,4 @@
1
- import { type AnyNodeId, type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
1
+ import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { Building2, Plus } from 'lucide-react'
4
4
  import { memo, useState } from 'react'
@@ -12,7 +12,7 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
12
12
  import { TreeNodeActions } from './tree-node-actions'
13
13
 
14
14
  interface BuildingTreeNodeProps {
15
- nodeId: AnyNodeId
15
+ nodeId: BuildingNode['id']
16
16
  depth: number
17
17
  isLast?: boolean
18
18
  }
@@ -0,0 +1,77 @@
1
+ import { type AnyNodeId, type ColumnNode, useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import Image from 'next/image'
4
+ import { memo, useCallback, useState } from 'react'
5
+ import useEditor from './../../../../../store/use-editor'
6
+ import { InlineRenameInput } from './inline-rename-input'
7
+ import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
8
+ import { TreeNodeActions } from './tree-node-actions'
9
+
10
+ interface ColumnTreeNodeProps {
11
+ nodeId: AnyNodeId
12
+ depth: number
13
+ isLast?: boolean
14
+ }
15
+
16
+ export const ColumnTreeNode = memo(function ColumnTreeNode({
17
+ nodeId,
18
+ depth,
19
+ isLast,
20
+ }: ColumnTreeNodeProps) {
21
+ const [isEditing, setIsEditing] = useState(false)
22
+ const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
23
+ const node = useScene((s) => s.nodes[nodeId] as ColumnNode | undefined)
24
+ const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
25
+ const isHovered = useViewer((state) => state.hoveredId === nodeId)
26
+ const setSelection = useViewer((state) => state.setSelection)
27
+ const setHoveredId = useViewer((state) => state.setHoveredId)
28
+
29
+ const handleClick = useCallback(
30
+ (e: React.MouseEvent) => {
31
+ e.stopPropagation()
32
+ const handled = handleTreeSelection(
33
+ e,
34
+ nodeId,
35
+ useViewer.getState().selection.selectedIds,
36
+ setSelection,
37
+ )
38
+ if (!handled && useEditor.getState().phase === 'furnish') {
39
+ useEditor.getState().setPhase('structure')
40
+ }
41
+ },
42
+ [nodeId, setSelection],
43
+ )
44
+
45
+ const defaultName = node?.name || 'Column'
46
+
47
+ return (
48
+ <TreeNodeWrapper
49
+ actions={<TreeNodeActions nodeId={nodeId} />}
50
+ depth={depth}
51
+ expanded={false}
52
+ hasChildren={false}
53
+ icon={
54
+ <Image alt="" className="object-contain" height={14} src="/icons/column.png" width={14} />
55
+ }
56
+ isHovered={isHovered}
57
+ isLast={isLast}
58
+ isSelected={isSelected}
59
+ isVisible={isVisible}
60
+ label={
61
+ <InlineRenameInput
62
+ defaultName={defaultName}
63
+ isEditing={isEditing}
64
+ nodeId={nodeId}
65
+ onStartEditing={() => setIsEditing(true)}
66
+ onStopEditing={() => setIsEditing(false)}
67
+ />
68
+ }
69
+ nodeId={nodeId}
70
+ onClick={handleClick}
71
+ onDoubleClick={() => focusTreeNode(nodeId)}
72
+ onMouseEnter={() => setHoveredId(nodeId)}
73
+ onMouseLeave={() => setHoveredId(null)}
74
+ onToggle={() => {}}
75
+ />
76
+ )
77
+ })