@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.
- package/package.json +13 -9
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +74 -5
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +24 -3
- package/src/components/editor/first-person/build-collider-world.ts +363 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9861 -3297
- package/src/components/editor/index.tsx +295 -32
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
- package/src/components/editor/thumbnail-generator.tsx +56 -68
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +267 -36
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +124 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1 -1
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +7 -0
- package/src/components/tools/door/move-door-tool.tsx +28 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +160 -4
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
- package/src/components/tools/fence/move-fence-tool.tsx +111 -40
- package/src/components/tools/item/move-tool.tsx +7 -1
- package/src/components/tools/item/placement-math.ts +32 -5
- package/src/components/tools/item/placement-strategies.ts +110 -31
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +1 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
- package/src/components/tools/roof/move-roof-tool.tsx +29 -17
- package/src/components/tools/select/box-select-tool.tsx +12 -17
- package/src/components/tools/shared/polygon-editor.tsx +153 -28
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/tool-manager.tsx +20 -5
- package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
- package/src/components/tools/wall/move-wall-tool.tsx +6 -4
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +136 -4
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/tools/zone/zone-tool.tsx +20 -5
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +34 -1
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +98 -59
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +418 -41
- package/src/components/ui/command-palette/editor-commands.tsx +24 -5
- package/src/components/ui/command-palette/index.tsx +4 -255
- package/src/components/ui/controls/material-picker.tsx +154 -164
- package/src/components/ui/controls/slider-control.tsx +66 -18
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +10 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
- package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
- package/src/components/ui/level-duplicate-dialog.tsx +113 -0
- package/src/components/ui/panels/ceiling-panel.tsx +3 -28
- package/src/components/ui/panels/column-panel.tsx +759 -0
- package/src/components/ui/panels/door-panel.tsx +989 -290
- package/src/components/ui/panels/fence-panel.tsx +2 -49
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +163 -0
- package/src/components/ui/panels/panel-manager.tsx +208 -28
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +253 -5
- package/src/components/ui/panels/roof-panel.tsx +13 -64
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
- package/src/components/ui/panels/slab-panel.tsx +4 -30
- package/src/components/ui/panels/spawn-panel.tsx +161 -0
- package/src/components/ui/panels/stair-panel.tsx +20 -74
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +10 -8
- package/src/components/ui/panels/window-panel.tsx +668 -139
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +0 -0
- package/src/components/viewer-zone-system.tsx +0 -0
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-auto-save.ts +14 -0
- package/src/hooks/use-keyboard.ts +74 -7
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +8 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/level-duplication.test.ts +70 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/scene.ts +0 -0
- package/src/lib/sfx-bus.ts +2 -0
- package/src/lib/sfx-player.ts +5 -5
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +186 -62
- package/tsconfig.json +2 -1
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- 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-
|
|
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 “{search}”
|
|
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
|
+
}
|
|
File without changes
|
|
@@ -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
|
|
92
|
-
{ keys: ['T'], action: 'Rotate item counter-clockwise
|
|
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
|
|
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:
|
|
15
|
+
nodeId: BuildingNode['id']
|
|
16
16
|
depth: number
|
|
17
17
|
isLast?: boolean
|
|
18
18
|
}
|
|
File without changes
|
|
@@ -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
|
+
})
|