@pascal-app/editor 0.7.0 → 0.9.1

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 (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,263 @@
1
+ 'use client'
2
+
3
+ import type { AssetInput } from '@pascal-app/core'
4
+ import { Root as TooltipRoot } from '@radix-ui/react-tooltip'
5
+ import NextImage from 'next/image'
6
+ import { useMemo, useState } from 'react'
7
+ import { triggerSFX } from '../../../../../lib/sfx-bus'
8
+ import { cn } from '../../../../../lib/utils'
9
+ import { ItemCatalog } from '../../../item-catalog/item-catalog'
10
+ import {
11
+ TooltipContent,
12
+ TooltipProvider,
13
+ TooltipTrigger,
14
+ } from '../../../../../components/ui/primitives/tooltip'
15
+
16
+ /** A function-axis taxonomy node, assembled into a tree by the embedder. */
17
+ export type FunctionTreeNode = {
18
+ slug: string
19
+ name: string
20
+ iconUrl?: string | null
21
+ children: FunctionTreeNode[]
22
+ }
23
+
24
+ const SOURCE_CHIPS: Array<{ id: NonNullable<AssetInput['source']>; label: string }> = [
25
+ { id: 'library', label: 'Library' },
26
+ { id: 'community', label: 'Community' },
27
+ { id: 'mine', label: 'Mine' },
28
+ ]
29
+
30
+ /** Every slug at or below `node`, so a non-leaf selection matches descendants. */
31
+ function descendantSlugs(node: FunctionTreeNode): Set<string> {
32
+ const out = new Set<string>()
33
+ const walk = (n: FunctionTreeNode) => {
34
+ out.add(n.slug)
35
+ for (const child of n.children) walk(child)
36
+ }
37
+ walk(node)
38
+ return out
39
+ }
40
+
41
+ function itemFunctionSlugs(item: AssetInput): string[] {
42
+ if (item.functionTags && item.functionTags.length > 0) return item.functionTags
43
+ return item.category ? [item.category] : []
44
+ }
45
+
46
+ /**
47
+ * DB-driven hierarchical Items browse. Roots render as the category tab bar;
48
+ * a selected root with children exposes those children as a secondary chip
49
+ * row. Selecting any node shows items tagged with that node or any descendant.
50
+ * Library / Community / Mine narrows by source on top of the tree selection.
51
+ */
52
+ export function FunctionTreePanel({
53
+ functionTree,
54
+ items,
55
+ onSearchChange,
56
+ searchResults,
57
+ leadingTile,
58
+ emptyState,
59
+ }: {
60
+ functionTree: FunctionTreeNode[]
61
+ items?: AssetInput[]
62
+ onSearchChange?: (query: string) => void
63
+ searchResults?: AssetInput[] | null
64
+ leadingTile?: React.ReactNode
65
+ emptyState?: React.ReactNode
66
+ }) {
67
+ const [activeRootSlug, setActiveRootSlug] = useState<string | null>(
68
+ functionTree[0]?.slug ?? null,
69
+ )
70
+ const [activeChildSlug, setActiveChildSlug] = useState<string | null>(null)
71
+ const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>('library')
72
+ const [search, setSearch] = useState('')
73
+
74
+ const isServerSearch = onSearchChange !== undefined
75
+ const isSearchPending = isServerSearch && search.length > 0 && searchResults === null
76
+
77
+ const activeRoot = functionTree.find((n) => n.slug === activeRootSlug) ?? functionTree[0]
78
+ const activeNode =
79
+ (activeChildSlug && activeRoot?.children.find((c) => c.slug === activeChildSlug)) || activeRoot
80
+
81
+ const matchesSource = (item: AssetInput) => {
82
+ if (!activeSource) return true
83
+ const itemSource = item.source ?? 'library'
84
+ if (activeSource === 'mine') return itemSource === 'mine'
85
+ if (activeSource === 'library') return itemSource === 'library'
86
+ if (activeSource === 'community') {
87
+ if (itemSource === 'community') return true
88
+ if (itemSource === 'mine') return !item.isDraft
89
+ return false
90
+ }
91
+ return true
92
+ }
93
+
94
+ const treeItems = useMemo(() => {
95
+ const base = items ?? []
96
+ if (!activeNode) return base.filter(matchesSource)
97
+ const slugs = descendantSlugs(activeNode)
98
+ return base.filter(
99
+ (item) => matchesSource(item) && itemFunctionSlugs(item).some((s) => slugs.has(s)),
100
+ )
101
+ // eslint-disable-next-line react-hooks/exhaustive-deps
102
+ }, [items, activeNode, activeSource])
103
+
104
+ const searchItems = useMemo(() => {
105
+ if (!(isServerSearch && search && searchResults)) return null
106
+ return activeSource ? searchResults.filter(matchesSource) : searchResults
107
+ // eslint-disable-next-line react-hooks/exhaustive-deps
108
+ }, [isServerSearch, search, searchResults, activeSource])
109
+
110
+ function selectRoot(slug: string) {
111
+ setActiveRootSlug(slug)
112
+ setActiveChildSlug(null)
113
+ setSearch('')
114
+ onSearchChange?.('')
115
+ }
116
+
117
+ return (
118
+ <div className="flex h-full flex-col">
119
+ {/* Root nodes as a category grid — icon when available, otherwise a
120
+ two-letter abbreviation, with the full name in a hover tooltip.
121
+ Mirrors the Build tab's tile grid so the two panels read the same. */}
122
+ <TooltipProvider delayDuration={0} disableHoverableContent>
123
+ <div className="grid shrink-0 grid-cols-5 gap-1.5 border-border/70 border-b p-2">
124
+ {functionTree.map((root) => {
125
+ const isActive = activeRoot?.slug === root.slug
126
+ return (
127
+ <TooltipRoot key={root.slug}>
128
+ <TooltipTrigger asChild>
129
+ <button
130
+ className={cn(
131
+ 'relative flex aspect-square items-center justify-center rounded-xl transition-all duration-200',
132
+ isActive
133
+ ? 'bg-primary/10 ring-1 ring-primary/50'
134
+ : 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
135
+ )}
136
+ onClick={() => {
137
+ triggerSFX('sfx:menu-click')
138
+ selectRoot(root.slug)
139
+ }}
140
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
141
+ type="button"
142
+ >
143
+ {root.iconUrl ? (
144
+ <NextImage
145
+ alt={root.name}
146
+ className="size-7 object-contain"
147
+ height={28}
148
+ src={root.iconUrl}
149
+ width={28}
150
+ />
151
+ ) : (
152
+ <span className="font-semibold text-muted-foreground text-xs uppercase">
153
+ {root.name.slice(0, 2)}
154
+ </span>
155
+ )}
156
+ </button>
157
+ </TooltipTrigger>
158
+ <TooltipContent className="icon-grid-tooltip pointer-events-none" side="top">
159
+ {root.name}
160
+ </TooltipContent>
161
+ </TooltipRoot>
162
+ )
163
+ })}
164
+ </div>
165
+ </TooltipProvider>
166
+
167
+ {/* Search + source filter */}
168
+ <div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
169
+ <div className="flex items-center gap-1.5">
170
+ <input
171
+ 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"
172
+ onChange={(e) => {
173
+ setSearch(e.target.value)
174
+ onSearchChange?.(e.target.value)
175
+ }}
176
+ placeholder="Search..."
177
+ type="text"
178
+ value={search}
179
+ />
180
+ <div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
181
+ {SOURCE_CHIPS.map((chip) => {
182
+ const isActive = activeSource === chip.id
183
+ return (
184
+ <button
185
+ className={cn(
186
+ 'min-w-0 flex-1 truncate rounded-md px-1 py-1 text-center font-medium text-[10px] transition-colors',
187
+ isActive
188
+ ? 'bg-background text-foreground shadow-sm'
189
+ : 'text-muted-foreground hover:text-foreground',
190
+ )}
191
+ key={chip.id}
192
+ onClick={() => setActiveSource(isActive ? null : chip.id)}
193
+ type="button"
194
+ >
195
+ {chip.label}
196
+ </button>
197
+ )
198
+ })}
199
+ </div>
200
+ </div>
201
+
202
+ {/* Child nodes of the active root as a secondary chip row */}
203
+ {!search && activeRoot && activeRoot.children.length > 0 && (
204
+ <div className="flex flex-wrap gap-1">
205
+ <button
206
+ className={cn(
207
+ 'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
208
+ activeChildSlug === null
209
+ ? 'bg-violet-500 text-white'
210
+ : 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
211
+ )}
212
+ onClick={() => setActiveChildSlug(null)}
213
+ type="button"
214
+ >
215
+ All
216
+ </button>
217
+ {activeRoot.children.map((child) => {
218
+ const isActive = activeChildSlug === child.slug
219
+ return (
220
+ <button
221
+ className={cn(
222
+ 'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs capitalize transition-colors',
223
+ isActive
224
+ ? 'bg-violet-500 text-white'
225
+ : 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
226
+ )}
227
+ key={child.slug}
228
+ onClick={() => setActiveChildSlug(isActive ? null : child.slug)}
229
+ type="button"
230
+ >
231
+ {child.name}
232
+ </button>
233
+ )
234
+ })}
235
+ </div>
236
+ )}
237
+ </div>
238
+
239
+ {/* Item grid */}
240
+ <div className="min-h-0 flex-1 overflow-y-auto p-3">
241
+ {isSearchPending ? (
242
+ <div className="flex h-full items-center justify-center">
243
+ <div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground" />
244
+ </div>
245
+ ) : isServerSearch && search && searchResults?.length === 0 ? (
246
+ (emptyState ?? (
247
+ <div className="flex h-full items-center justify-center text-muted-foreground text-xs">
248
+ No results for &ldquo;{search}&rdquo;
249
+ </div>
250
+ ))
251
+ ) : (
252
+ <ItemCatalog
253
+ category={'furnish' as never}
254
+ emptyState={emptyState}
255
+ key={activeNode?.slug ?? 'all'}
256
+ leadingTile={leadingTile}
257
+ overrideItems={isServerSearch && search ? (searchItems ?? undefined) : treeItems}
258
+ />
259
+ )}
260
+ </div>
261
+ </div>
262
+ )
263
+ }
@@ -0,0 +1,404 @@
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 { triggerSFX } from '../../../../../lib/sfx-bus'
7
+ import { cn } from '../../../../../lib/utils'
8
+ import type { CatalogCategory } from '../../../../../store/use-editor'
9
+ import useEditor from '../../../../../store/use-editor'
10
+ import { furnishTools } from '../../../action-menu/furnish-tools'
11
+ import { CATALOG_ITEMS } from '../../../item-catalog/catalog-items'
12
+ import { ItemCatalog } from '../../../item-catalog/item-catalog'
13
+ import { type FunctionTreeNode, FunctionTreePanel } from './function-tree-panel'
14
+
15
+ const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
16
+
17
+ export function ItemsPanel({
18
+ items,
19
+ onSearchChange,
20
+ searchResults,
21
+ leadingTile,
22
+ emptyState,
23
+ functionTree,
24
+ showSourceFilter = true,
25
+ showTagFilters = true,
26
+ }: {
27
+ items?: AssetInput[]
28
+ /** Called when the search query changes (community edition uses this for server-side search) */
29
+ onSearchChange?: (query: string) => void
30
+ /** When non-null and search is active, these results bypass local filtering (server search results) */
31
+ searchResults?: AssetInput[] | null
32
+ /**
33
+ * Optional node rendered as the first grid cell, always visible. Used by the
34
+ * community edition to inject a "+ Generate with AI" tile.
35
+ */
36
+ leadingTile?: React.ReactNode
37
+ /**
38
+ * Optional node rendered when the grid has no items to show (empty category
39
+ * or no search results). Replaces the default "No results" message.
40
+ */
41
+ emptyState?: React.ReactNode
42
+ /**
43
+ * DB-driven function taxonomy. When provided, the panel renders the
44
+ * hierarchical tree browse instead of the legacy hardcoded category tabs.
45
+ */
46
+ functionTree?: FunctionTreeNode[]
47
+ /**
48
+ * Library/Community/Mine source chips. The open-source editor has no
49
+ * uploaded items (only the built-in catalog), so it hides these.
50
+ */
51
+ showSourceFilter?: boolean
52
+ /**
53
+ * Placement/functional tag filter chips under the search row. The
54
+ * open-source editor hides these to keep the panel to plain categories.
55
+ */
56
+ showTagFilters?: boolean
57
+ }) {
58
+ // When the embedder supplies a function taxonomy, the hierarchical browse
59
+ // replaces the legacy `furnishTools` category tabs entirely.
60
+ if (functionTree && functionTree.length > 0) {
61
+ return (
62
+ <FunctionTreePanel
63
+ emptyState={emptyState}
64
+ functionTree={functionTree}
65
+ items={items}
66
+ leadingTile={leadingTile}
67
+ onSearchChange={onSearchChange}
68
+ searchResults={searchResults}
69
+ />
70
+ )
71
+ }
72
+
73
+ return <LegacyItemsPanel
74
+ emptyState={emptyState}
75
+ items={items}
76
+ leadingTile={leadingTile}
77
+ onSearchChange={onSearchChange}
78
+ searchResults={searchResults}
79
+ showSourceFilter={showSourceFilter}
80
+ showTagFilters={showTagFilters}
81
+ />
82
+ }
83
+
84
+ function LegacyItemsPanel({
85
+ items,
86
+ onSearchChange,
87
+ searchResults,
88
+ leadingTile,
89
+ emptyState,
90
+ showSourceFilter = true,
91
+ showTagFilters = true,
92
+ }: {
93
+ items?: AssetInput[]
94
+ onSearchChange?: (query: string) => void
95
+ searchResults?: AssetInput[] | null
96
+ leadingTile?: React.ReactNode
97
+ emptyState?: React.ReactNode
98
+ showSourceFilter?: boolean
99
+ showTagFilters?: boolean
100
+ }) {
101
+ const mode = useEditor((s) => s.mode)
102
+ const catalogCategory = useEditor((s) => s.catalogCategory)
103
+ const setMode = useEditor((s) => s.setMode)
104
+ const setTool = useEditor((s) => s.setTool)
105
+ const setCatalogCategory = useEditor((s) => s.setCatalogCategory)
106
+
107
+ const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null)
108
+ const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
109
+ // Library / Community / Mine. Default to Library so first-time users see
110
+ // the curated catalog rather than every uploaded item; clicking the chip
111
+ // again clears the filter (`null` = show everything). With the chips hidden
112
+ // there is nothing to filter by, so start unfiltered.
113
+ const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>(
114
+ showSourceFilter ? 'library' : null,
115
+ )
116
+ const [search, setSearch] = useState('')
117
+ const isServerSearch = onSearchChange !== undefined
118
+ // True when server search is active but results haven't come back yet
119
+ const isSearchPending = isServerSearch && search.length > 0 && searchResults === null
120
+
121
+ // Auto-select the first category when the panel mounts without one
122
+ useEffect(() => {
123
+ if (!(catalogCategory && furnishTools.some((c) => c.catalogCategory === catalogCategory))) {
124
+ setCatalogCategory(furnishTools[0]!.catalogCategory)
125
+ }
126
+ }, [catalogCategory, setCatalogCategory])
127
+
128
+ const activeCategory =
129
+ furnishTools.find((c) => c.catalogCategory === catalogCategory) ?? furnishTools[0]!
130
+
131
+ function selectCategory(categoryId: CatalogCategory) {
132
+ setCatalogCategory(categoryId)
133
+ setTool('item')
134
+ setActivePlacementTag(null)
135
+ setActiveFunctionalTag(null)
136
+ setSearch('')
137
+ if (mode !== 'build') setMode('build')
138
+ }
139
+
140
+ // Compute tags for the current category (for filter chips)
141
+ const baseItems = items ?? CATALOG_ITEMS
142
+ // Apply the Library/Community/Mine filter before any category/tag work.
143
+ // Items that don't carry a source field (e.g. seeded built-in catalog
144
+ // entries from `CATALOG_ITEMS`) fall under "library".
145
+ //
146
+ // Community is broader than just other users' uploads: my own *published*
147
+ // items show up there too so I can preview my catalog the way other users
148
+ // see it. My drafts only appear under Mine.
149
+ const matchesSource = (item: AssetInput) => {
150
+ if (!activeSource) return true
151
+ const itemSource = item.source ?? 'library'
152
+ if (activeSource === 'mine') return itemSource === 'mine'
153
+ if (activeSource === 'library') return itemSource === 'library'
154
+ if (activeSource === 'community') {
155
+ if (itemSource === 'community') return true
156
+ if (itemSource === 'mine') return !item.isDraft
157
+ return false
158
+ }
159
+ return true
160
+ }
161
+ const sourceItems = baseItems.filter(matchesSource)
162
+ const categoryItems = sourceItems.filter(
163
+ (item) => item.category === activeCategory.catalogCategory,
164
+ )
165
+
166
+ // The three source chips are always shown so users can discover the
167
+ // filter even before they own any items. Selecting "Mine" with no
168
+ // matching items falls through to the empty/no-results state.
169
+ const sourceChips: Array<{ id: AssetInput['source']; label: string }> = [
170
+ { id: 'library', label: 'Library' },
171
+ { id: 'community', label: 'Community' },
172
+ { id: 'mine', label: 'Mine' },
173
+ ]
174
+ const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
175
+ const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
176
+ const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))
177
+ const hasFilters = showTagFilters && allTags.length > 1
178
+
179
+ const placementCount = (tag: string | null) =>
180
+ categoryItems.filter((item) => {
181
+ const tags = item.tags ?? []
182
+ if (tag !== null && !tags.includes(tag)) return false
183
+ if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
184
+ return true
185
+ }).length
186
+
187
+ const functionalCount = (tag: string) =>
188
+ categoryItems.filter((item) => {
189
+ const tags = item.tags ?? []
190
+ if (!tags.includes(tag)) return false
191
+ if (activePlacementTag && !tags.includes(activePlacementTag)) return false
192
+ return true
193
+ }).length
194
+
195
+ return (
196
+ <div className="flex h-full flex-col">
197
+ {/* Category tabs */}
198
+ <div className="flex shrink-0 flex-wrap gap-1 border-border/70 border-b p-2">
199
+ {furnishTools.map((cat) => {
200
+ const isActive = activeCategory.catalogCategory === cat.catalogCategory
201
+ return (
202
+ <button
203
+ className={cn(
204
+ 'flex shrink-0 flex-col items-center gap-1 rounded-xl px-3 py-2 transition-colors',
205
+ isActive
206
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground'
207
+ : 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground',
208
+ )}
209
+ key={cat.catalogCategory}
210
+ onClick={() => {
211
+ triggerSFX('sfx:menu-click')
212
+ selectCategory(cat.catalogCategory)
213
+ }}
214
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
215
+ type="button"
216
+ >
217
+ <NextImage
218
+ alt={cat.label}
219
+ className={cn('size-7 object-contain', !isActive && 'opacity-60 grayscale')}
220
+ height={28}
221
+ src={cat.iconSrc}
222
+ width={28}
223
+ />
224
+ <span className="font-medium text-[10px] leading-none">{cat.label}</span>
225
+ </button>
226
+ )
227
+ })}
228
+ </div>
229
+
230
+ {/* Search + filters (non-scrollable) */}
231
+ <div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
232
+ <div className="flex items-center gap-1.5">
233
+ {/* Search and source filter take 50/50 of the row. `min-w-0` on
234
+ both sides lets each half shrink to fit when the panel narrows.
235
+ With the source chips hidden, search spans the full row. */}
236
+ <input
237
+ className={cn(
238
+ 'min-w-0 shrink-0 rounded-lg bg-muted px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-none',
239
+ showSourceFilter ? 'w-1/2' : 'w-full',
240
+ )}
241
+ onChange={(e) => {
242
+ setSearch(e.target.value)
243
+ onSearchChange?.(e.target.value)
244
+ }}
245
+ placeholder="Search..."
246
+ type="text"
247
+ value={search}
248
+ />
249
+ {showSourceFilter && sourceChips.length > 0 && (
250
+ <div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
251
+ {sourceChips.map((chip) => {
252
+ const isActive = activeSource === chip.id
253
+ return (
254
+ <button
255
+ className={cn(
256
+ 'min-w-0 flex-1 truncate rounded-md px-1 py-1 text-center font-medium text-[10px] transition-colors',
257
+ isActive
258
+ ? 'bg-background text-foreground shadow-sm'
259
+ : 'text-muted-foreground hover:text-foreground',
260
+ )}
261
+ key={chip.id}
262
+ onClick={() => setActiveSource(isActive ? null : chip.id)}
263
+ type="button"
264
+ >
265
+ {chip.label}
266
+ </button>
267
+ )
268
+ })}
269
+ </div>
270
+ )}
271
+ </div>
272
+
273
+ {hasFilters && !search && !isServerSearch && (
274
+ <div className="flex flex-col gap-1.5">
275
+ {placementTags.length > 0 && (
276
+ <div className="flex flex-wrap gap-1">
277
+ <button
278
+ className={cn(
279
+ 'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
280
+ activePlacementTag === null
281
+ ? 'bg-blue-500 text-white'
282
+ : 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
283
+ )}
284
+ onClick={() => setActivePlacementTag(null)}
285
+ type="button"
286
+ >
287
+ All
288
+ </button>
289
+ {placementTags.map((tag) => {
290
+ const count = placementCount(tag)
291
+ const isActive = activePlacementTag === tag
292
+ const isEmpty = count === 0 && !isActive
293
+ return (
294
+ <button
295
+ className={cn(
296
+ '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',
297
+ isActive
298
+ ? 'bg-blue-500 text-white'
299
+ : isEmpty
300
+ ? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
301
+ : 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
302
+ )}
303
+ disabled={isEmpty}
304
+ key={tag}
305
+ onClick={() => setActivePlacementTag(isActive ? null : tag)}
306
+ type="button"
307
+ >
308
+ {tag}
309
+ <span
310
+ className={cn(
311
+ 'text-[10px]',
312
+ isActive
313
+ ? 'text-blue-200'
314
+ : isEmpty
315
+ ? 'text-zinc-600'
316
+ : 'text-blue-500/70',
317
+ )}
318
+ >
319
+ {count}
320
+ </span>
321
+ </button>
322
+ )
323
+ })}
324
+ </div>
325
+ )}
326
+
327
+ {functionalTags.length > 0 && (
328
+ <div className="flex flex-wrap gap-1">
329
+ {functionalTags.map((tag) => {
330
+ const count = functionalCount(tag)
331
+ const isActive = activeFunctionalTag === tag
332
+ const isEmpty = count === 0 && !isActive
333
+ return (
334
+ <button
335
+ className={cn(
336
+ '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',
337
+ isActive
338
+ ? 'bg-violet-500 text-white'
339
+ : isEmpty
340
+ ? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
341
+ : 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
342
+ )}
343
+ disabled={isEmpty}
344
+ key={tag}
345
+ onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
346
+ type="button"
347
+ >
348
+ {tag}
349
+ <span
350
+ className={cn(
351
+ 'text-[10px]',
352
+ isActive
353
+ ? 'text-violet-200'
354
+ : isEmpty
355
+ ? 'text-zinc-600'
356
+ : 'text-zinc-500/70',
357
+ )}
358
+ >
359
+ {count}
360
+ </span>
361
+ </button>
362
+ )
363
+ })}
364
+ </div>
365
+ )}
366
+ </div>
367
+ )}
368
+ </div>
369
+
370
+ {/* Item grid */}
371
+ <div className="min-h-0 flex-1 overflow-y-auto p-3">
372
+ {isSearchPending ? (
373
+ <div className="flex h-full items-center justify-center">
374
+ <div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground" />
375
+ </div>
376
+ ) : isServerSearch && search && searchResults?.length === 0 ? (
377
+ (emptyState ?? (
378
+ <div className="flex h-full items-center justify-center text-muted-foreground text-xs">
379
+ No results for &ldquo;{search}&rdquo;
380
+ </div>
381
+ ))
382
+ ) : (
383
+ <ItemCatalog
384
+ activeFunctionalTag={isServerSearch ? null : activeFunctionalTag}
385
+ activePlacementTag={isServerSearch ? null : activePlacementTag}
386
+ category={activeCategory.catalogCategory}
387
+ emptyState={emptyState}
388
+ items={activeSource && items ? items.filter(matchesSource) : items}
389
+ key={activeCategory.catalogCategory}
390
+ leadingTile={leadingTile}
391
+ overrideItems={
392
+ isServerSearch && search
393
+ ? activeSource && searchResults
394
+ ? searchResults.filter(matchesSource)
395
+ : (searchResults ?? undefined)
396
+ : undefined
397
+ }
398
+ search={isServerSearch ? '' : search}
399
+ />
400
+ )}
401
+ </div>
402
+ </div>
403
+ )
404
+ }