@pascal-app/editor 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { emitter } from '@pascal-app/core'
|
|
4
|
+
import {
|
|
5
|
+
BookMarked,
|
|
6
|
+
Check,
|
|
7
|
+
Globe,
|
|
8
|
+
GlobeLock,
|
|
9
|
+
MoreHorizontal,
|
|
10
|
+
Pencil,
|
|
11
|
+
Plus,
|
|
12
|
+
Save,
|
|
13
|
+
Trash2,
|
|
14
|
+
Users,
|
|
15
|
+
X,
|
|
16
|
+
} from 'lucide-react'
|
|
17
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
18
|
+
import type { PresetsTab } from '../../../../contexts/presets-context'
|
|
19
|
+
import { cn } from '../../../../lib/utils'
|
|
20
|
+
import {
|
|
21
|
+
DropdownMenu,
|
|
22
|
+
DropdownMenuContent,
|
|
23
|
+
DropdownMenuItem,
|
|
24
|
+
DropdownMenuTrigger,
|
|
25
|
+
} from '../../primitives/dropdown-menu'
|
|
26
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover'
|
|
27
|
+
|
|
28
|
+
export type PresetType = 'door' | 'window'
|
|
29
|
+
|
|
30
|
+
export interface PresetData {
|
|
31
|
+
id: string
|
|
32
|
+
type: string
|
|
33
|
+
name: string
|
|
34
|
+
data: Record<string, unknown>
|
|
35
|
+
thumbnail_url: string | null
|
|
36
|
+
user_id: string | null
|
|
37
|
+
is_community: boolean
|
|
38
|
+
created_at: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface PresetsPopoverProps {
|
|
42
|
+
type: PresetType
|
|
43
|
+
children: React.ReactNode
|
|
44
|
+
isAuthenticated?: boolean
|
|
45
|
+
tabs?: PresetsTab[]
|
|
46
|
+
onFetchPresets: (tab: PresetsTab) => Promise<PresetData[]>
|
|
47
|
+
onApply: (data: Record<string, unknown>) => void
|
|
48
|
+
onSave: (name: string) => Promise<void>
|
|
49
|
+
onOverwrite: (id: string) => Promise<void>
|
|
50
|
+
onRename: (id: string, name: string) => Promise<void>
|
|
51
|
+
onDelete: (id: string) => Promise<void>
|
|
52
|
+
onToggleCommunity?: (id: string, current: boolean) => Promise<void>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function PresetsPopover({
|
|
56
|
+
type,
|
|
57
|
+
onApply,
|
|
58
|
+
onSave,
|
|
59
|
+
onOverwrite,
|
|
60
|
+
onFetchPresets,
|
|
61
|
+
onRename,
|
|
62
|
+
onDelete,
|
|
63
|
+
onToggleCommunity,
|
|
64
|
+
children,
|
|
65
|
+
isAuthenticated = false,
|
|
66
|
+
tabs = ['community', 'mine'],
|
|
67
|
+
}: PresetsPopoverProps) {
|
|
68
|
+
const defaultTab = tabs[0] ?? 'mine'
|
|
69
|
+
const [open, setOpen] = useState(false)
|
|
70
|
+
const [tab, setTab] = useState<PresetsTab>(defaultTab)
|
|
71
|
+
const [presets, setPresets] = useState<PresetData[]>([])
|
|
72
|
+
const [loading, setLoading] = useState(false)
|
|
73
|
+
|
|
74
|
+
const [showSaveInput, setShowSaveInput] = useState(false)
|
|
75
|
+
const [saveName, setSaveName] = useState('')
|
|
76
|
+
const [saving, setSaving] = useState(false)
|
|
77
|
+
|
|
78
|
+
const [renamingId, setRenamingId] = useState<string | null>(null)
|
|
79
|
+
const [renameValue, setRenameValue] = useState('')
|
|
80
|
+
const [deletingId, setDeletingId] = useState<string | null>(null)
|
|
81
|
+
const [overwrittenId, setOverwrittenId] = useState<string | null>(null)
|
|
82
|
+
|
|
83
|
+
const fetchPresets = useCallback(async () => {
|
|
84
|
+
setLoading(true)
|
|
85
|
+
try {
|
|
86
|
+
const data = await onFetchPresets(tab)
|
|
87
|
+
setPresets(data)
|
|
88
|
+
} finally {
|
|
89
|
+
setLoading(false)
|
|
90
|
+
}
|
|
91
|
+
}, [onFetchPresets, tab])
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (open) fetchPresets()
|
|
95
|
+
}, [open, fetchPresets])
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!isAuthenticated && tab === 'mine') setTab(defaultTab)
|
|
99
|
+
}, [isAuthenticated, tab, defaultTab])
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const handler = ({ presetId, thumbnailUrl }: { presetId: string; thumbnailUrl: string }) => {
|
|
103
|
+
setPresets((prev) =>
|
|
104
|
+
prev.map((p) => (p.id === presetId ? { ...p, thumbnail_url: thumbnailUrl } : p)),
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
emitter.on('preset:thumbnail-updated', handler)
|
|
108
|
+
return () => emitter.off('preset:thumbnail-updated', handler)
|
|
109
|
+
}, [])
|
|
110
|
+
|
|
111
|
+
const handleSaveNew = async () => {
|
|
112
|
+
if (!saveName.trim()) return
|
|
113
|
+
setSaving(true)
|
|
114
|
+
try {
|
|
115
|
+
await onSave(saveName.trim())
|
|
116
|
+
setSaveName('')
|
|
117
|
+
setShowSaveInput(false)
|
|
118
|
+
if (tab === 'mine') fetchPresets()
|
|
119
|
+
else setTab('mine')
|
|
120
|
+
} finally {
|
|
121
|
+
setSaving(false)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const handleRename = async (id: string) => {
|
|
126
|
+
if (!renameValue.trim()) return
|
|
127
|
+
await onRename(id, renameValue.trim())
|
|
128
|
+
setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, name: renameValue.trim() } : p)))
|
|
129
|
+
setRenamingId(null)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const handleDelete = async (id: string) => {
|
|
133
|
+
await onDelete(id)
|
|
134
|
+
setPresets((prev) => prev.filter((p) => p.id !== id))
|
|
135
|
+
setDeletingId(null)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const handleOverwrite = async (id: string) => {
|
|
139
|
+
await onOverwrite(id)
|
|
140
|
+
setOverwrittenId(id)
|
|
141
|
+
setTimeout(() => setOverwrittenId(null), 1500)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const handleToggleCommunity = async (id: string, current: boolean) => {
|
|
145
|
+
if (!onToggleCommunity) return
|
|
146
|
+
await onToggleCommunity(id, current)
|
|
147
|
+
setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, is_community: !current } : p)))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const showTabs = tabs.length > 1
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<Popover onOpenChange={setOpen} open={open}>
|
|
154
|
+
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
|
155
|
+
<PopoverContent
|
|
156
|
+
align="start"
|
|
157
|
+
className="w-72 overflow-hidden rounded-xl border-border/50 bg-sidebar/95 p-0 shadow-2xl backdrop-blur-xl"
|
|
158
|
+
side="left"
|
|
159
|
+
sideOffset={8}
|
|
160
|
+
>
|
|
161
|
+
<div className="flex items-center justify-between border-border/50 border-b px-3 py-2.5">
|
|
162
|
+
<div className="flex items-center gap-1.5">
|
|
163
|
+
<BookMarked className="h-3.5 w-3.5 text-muted-foreground" />
|
|
164
|
+
<span className="font-semibold text-foreground text-xs tracking-tight">
|
|
165
|
+
{type === 'door' ? 'Door' : 'Window'} Presets
|
|
166
|
+
</span>
|
|
167
|
+
</div>
|
|
168
|
+
{isAuthenticated && (
|
|
169
|
+
<button
|
|
170
|
+
className="flex items-center gap-1 rounded-md px-2 py-1 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
|
171
|
+
onClick={() => {
|
|
172
|
+
setShowSaveInput((v) => !v)
|
|
173
|
+
setSaveName('')
|
|
174
|
+
}}
|
|
175
|
+
type="button"
|
|
176
|
+
>
|
|
177
|
+
<Plus className="h-3 w-3" />
|
|
178
|
+
Save new
|
|
179
|
+
</button>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{showSaveInput && (
|
|
184
|
+
<div className="flex items-center gap-1.5 border-border/50 border-b bg-white/5 px-3 py-2">
|
|
185
|
+
<input
|
|
186
|
+
autoFocus
|
|
187
|
+
className="min-w-0 flex-1 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-foreground text-xs outline-none placeholder:text-muted-foreground/60 focus:border-ring focus:ring-1 focus:ring-ring/30"
|
|
188
|
+
onChange={(e) => setSaveName(e.target.value)}
|
|
189
|
+
onKeyDown={(e) => {
|
|
190
|
+
if (e.key === 'Enter') handleSaveNew()
|
|
191
|
+
if (e.key === 'Escape') {
|
|
192
|
+
setShowSaveInput(false)
|
|
193
|
+
setSaveName('')
|
|
194
|
+
}
|
|
195
|
+
}}
|
|
196
|
+
placeholder="Preset name…"
|
|
197
|
+
value={saveName}
|
|
198
|
+
/>
|
|
199
|
+
<button
|
|
200
|
+
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 text-primary transition-colors hover:bg-primary/30 disabled:opacity-40"
|
|
201
|
+
disabled={!saveName.trim() || saving}
|
|
202
|
+
onClick={handleSaveNew}
|
|
203
|
+
type="button"
|
|
204
|
+
>
|
|
205
|
+
<Check className="h-3.5 w-3.5" />
|
|
206
|
+
</button>
|
|
207
|
+
<button
|
|
208
|
+
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10"
|
|
209
|
+
onClick={() => {
|
|
210
|
+
setShowSaveInput(false)
|
|
211
|
+
setSaveName('')
|
|
212
|
+
}}
|
|
213
|
+
type="button"
|
|
214
|
+
>
|
|
215
|
+
<X className="h-3.5 w-3.5" />
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{showTabs && (
|
|
221
|
+
<div className="flex border-border/50 border-b">
|
|
222
|
+
{tabs.includes('community') && (
|
|
223
|
+
<TabButton active={tab === 'community'} onClick={() => setTab('community')}>
|
|
224
|
+
<Users className="h-3 w-3" />
|
|
225
|
+
Community
|
|
226
|
+
</TabButton>
|
|
227
|
+
)}
|
|
228
|
+
{tabs.includes('mine') && (
|
|
229
|
+
<TabButton
|
|
230
|
+
active={tab === 'mine'}
|
|
231
|
+
disabled={!isAuthenticated}
|
|
232
|
+
onClick={() => {
|
|
233
|
+
if (isAuthenticated) setTab('mine')
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
<BookMarked className="h-3 w-3" />
|
|
237
|
+
My presets
|
|
238
|
+
</TabButton>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
<div className="no-scrollbar max-h-72 overflow-y-auto">
|
|
244
|
+
{loading ? (
|
|
245
|
+
<div className="flex items-center justify-center py-8">
|
|
246
|
+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
|
247
|
+
</div>
|
|
248
|
+
) : presets.length === 0 ? (
|
|
249
|
+
<EmptyState isAuthenticated={isAuthenticated} tab={tab} />
|
|
250
|
+
) : (
|
|
251
|
+
<ul className="divide-y divide-border/30">
|
|
252
|
+
{presets.map((preset) => (
|
|
253
|
+
<PresetRow
|
|
254
|
+
deletingId={deletingId}
|
|
255
|
+
isMine={tab === 'mine'}
|
|
256
|
+
key={preset.id}
|
|
257
|
+
onApply={() => {
|
|
258
|
+
onApply(preset.data)
|
|
259
|
+
setOpen(false)
|
|
260
|
+
}}
|
|
261
|
+
onDeleteCancel={() => setDeletingId(null)}
|
|
262
|
+
onDeleteConfirm={() => handleDelete(preset.id)}
|
|
263
|
+
onDeleteRequest={() => setDeletingId(preset.id)}
|
|
264
|
+
onOverwrite={() => handleOverwrite(preset.id)}
|
|
265
|
+
onRenameCancel={() => setRenamingId(null)}
|
|
266
|
+
onRenameChange={setRenameValue}
|
|
267
|
+
onRenameConfirm={() => handleRename(preset.id)}
|
|
268
|
+
onStartRename={() => {
|
|
269
|
+
setRenamingId(preset.id)
|
|
270
|
+
setRenameValue(preset.name)
|
|
271
|
+
}}
|
|
272
|
+
onToggleCommunity={() => handleToggleCommunity(preset.id, preset.is_community)}
|
|
273
|
+
overwrittenId={overwrittenId}
|
|
274
|
+
preset={preset}
|
|
275
|
+
renameValue={renameValue}
|
|
276
|
+
renamingId={renamingId}
|
|
277
|
+
showCommunityToggle={!!onToggleCommunity}
|
|
278
|
+
/>
|
|
279
|
+
))}
|
|
280
|
+
</ul>
|
|
281
|
+
)}
|
|
282
|
+
</div>
|
|
283
|
+
</PopoverContent>
|
|
284
|
+
</Popover>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function TabButton({
|
|
289
|
+
active,
|
|
290
|
+
onClick,
|
|
291
|
+
disabled,
|
|
292
|
+
children,
|
|
293
|
+
}: {
|
|
294
|
+
active: boolean
|
|
295
|
+
onClick: () => void
|
|
296
|
+
disabled?: boolean
|
|
297
|
+
children: React.ReactNode
|
|
298
|
+
}) {
|
|
299
|
+
return (
|
|
300
|
+
<button
|
|
301
|
+
className={cn(
|
|
302
|
+
'flex flex-1 items-center justify-center gap-1.5 py-2 font-medium text-[11px] transition-colors',
|
|
303
|
+
active
|
|
304
|
+
? '-mb-px border-primary border-b-2 text-foreground'
|
|
305
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
306
|
+
disabled && 'cursor-not-allowed opacity-40',
|
|
307
|
+
)}
|
|
308
|
+
disabled={disabled}
|
|
309
|
+
onClick={onClick}
|
|
310
|
+
type="button"
|
|
311
|
+
>
|
|
312
|
+
{children}
|
|
313
|
+
</button>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function EmptyState({ tab, isAuthenticated }: { tab: PresetsTab; isAuthenticated: boolean }) {
|
|
318
|
+
return (
|
|
319
|
+
<div className="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center">
|
|
320
|
+
<BookMarked className="h-6 w-6 text-muted-foreground/40" />
|
|
321
|
+
<p className="text-muted-foreground text-xs">
|
|
322
|
+
{tab === 'community'
|
|
323
|
+
? 'No community presets yet.'
|
|
324
|
+
: isAuthenticated
|
|
325
|
+
? 'No presets saved yet. Use "Save new" to save the current configuration.'
|
|
326
|
+
: 'Sign in to save and view your presets.'}
|
|
327
|
+
</p>
|
|
328
|
+
</div>
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
interface PresetRowProps {
|
|
333
|
+
preset: PresetData
|
|
334
|
+
isMine: boolean
|
|
335
|
+
showCommunityToggle: boolean
|
|
336
|
+
renamingId: string | null
|
|
337
|
+
renameValue: string
|
|
338
|
+
deletingId: string | null
|
|
339
|
+
overwrittenId: string | null
|
|
340
|
+
onApply: () => void
|
|
341
|
+
onOverwrite: () => void
|
|
342
|
+
onToggleCommunity: () => void
|
|
343
|
+
onStartRename: () => void
|
|
344
|
+
onRenameChange: (v: string) => void
|
|
345
|
+
onRenameConfirm: () => void
|
|
346
|
+
onRenameCancel: () => void
|
|
347
|
+
onDeleteRequest: () => void
|
|
348
|
+
onDeleteConfirm: () => void
|
|
349
|
+
onDeleteCancel: () => void
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function PresetRow({
|
|
353
|
+
preset,
|
|
354
|
+
isMine,
|
|
355
|
+
showCommunityToggle,
|
|
356
|
+
renamingId,
|
|
357
|
+
renameValue,
|
|
358
|
+
deletingId,
|
|
359
|
+
overwrittenId,
|
|
360
|
+
onApply,
|
|
361
|
+
onOverwrite,
|
|
362
|
+
onToggleCommunity,
|
|
363
|
+
onStartRename,
|
|
364
|
+
onRenameChange,
|
|
365
|
+
onRenameConfirm,
|
|
366
|
+
onRenameCancel,
|
|
367
|
+
onDeleteRequest,
|
|
368
|
+
onDeleteConfirm,
|
|
369
|
+
onDeleteCancel,
|
|
370
|
+
}: PresetRowProps) {
|
|
371
|
+
const isRenaming = renamingId === preset.id
|
|
372
|
+
const isDeleting = deletingId === preset.id
|
|
373
|
+
const justOverwritten = overwrittenId === preset.id
|
|
374
|
+
|
|
375
|
+
if (isDeleting) {
|
|
376
|
+
return (
|
|
377
|
+
<li className="flex items-center justify-between gap-2 bg-red-500/10 px-3 py-2.5">
|
|
378
|
+
<span className="truncate text-foreground/80 text-xs">Delete "{preset.name}"?</span>
|
|
379
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
380
|
+
<button
|
|
381
|
+
className="rounded-md bg-red-500/20 px-2 py-0.5 font-medium text-[11px] text-red-400 transition-colors hover:bg-red-500/30"
|
|
382
|
+
onClick={onDeleteConfirm}
|
|
383
|
+
type="button"
|
|
384
|
+
>
|
|
385
|
+
Delete
|
|
386
|
+
</button>
|
|
387
|
+
<button
|
|
388
|
+
className="rounded-md px-2 py-0.5 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-white/10"
|
|
389
|
+
onClick={onDeleteCancel}
|
|
390
|
+
type="button"
|
|
391
|
+
>
|
|
392
|
+
Cancel
|
|
393
|
+
</button>
|
|
394
|
+
</div>
|
|
395
|
+
</li>
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (isRenaming) {
|
|
400
|
+
return (
|
|
401
|
+
<li className="flex items-center gap-1.5 px-3 py-2">
|
|
402
|
+
<input
|
|
403
|
+
autoFocus
|
|
404
|
+
className="min-w-0 flex-1 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-foreground text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
|
|
405
|
+
onChange={(e) => onRenameChange(e.target.value)}
|
|
406
|
+
onKeyDown={(e) => {
|
|
407
|
+
if (e.key === 'Enter') onRenameConfirm()
|
|
408
|
+
if (e.key === 'Escape') onRenameCancel()
|
|
409
|
+
}}
|
|
410
|
+
value={renameValue}
|
|
411
|
+
/>
|
|
412
|
+
<button
|
|
413
|
+
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 text-primary transition-colors hover:bg-primary/30"
|
|
414
|
+
onClick={onRenameConfirm}
|
|
415
|
+
type="button"
|
|
416
|
+
>
|
|
417
|
+
<Check className="h-3.5 w-3.5" />
|
|
418
|
+
</button>
|
|
419
|
+
<button
|
|
420
|
+
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10"
|
|
421
|
+
onClick={onRenameCancel}
|
|
422
|
+
type="button"
|
|
423
|
+
>
|
|
424
|
+
<X className="h-3.5 w-3.5" />
|
|
425
|
+
</button>
|
|
426
|
+
</li>
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return (
|
|
431
|
+
<li className="group flex items-center gap-2 px-3 py-2.5 transition-colors hover:bg-white/5">
|
|
432
|
+
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-md border border-border/40 bg-white/5">
|
|
433
|
+
{preset.thumbnail_url ? (
|
|
434
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
435
|
+
<img
|
|
436
|
+
alt={preset.name}
|
|
437
|
+
className="h-full w-full object-cover"
|
|
438
|
+
src={preset.thumbnail_url}
|
|
439
|
+
/>
|
|
440
|
+
) : (
|
|
441
|
+
<div className="flex h-full w-full items-center justify-center">
|
|
442
|
+
<div className="h-3 w-5 rounded-sm border border-muted-foreground/30" />
|
|
443
|
+
</div>
|
|
444
|
+
)}
|
|
445
|
+
</div>
|
|
446
|
+
<button className="min-w-0 flex-1 text-left" onClick={onApply} type="button">
|
|
447
|
+
<span className="flex items-center gap-1.5">
|
|
448
|
+
<span className="block truncate font-medium text-foreground text-xs group-hover:text-foreground/90">
|
|
449
|
+
{preset.name}
|
|
450
|
+
</span>
|
|
451
|
+
{isMine && preset.is_community && (
|
|
452
|
+
<Globe className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
|
|
453
|
+
)}
|
|
454
|
+
</span>
|
|
455
|
+
<span className="block text-[10px] text-muted-foreground/60">
|
|
456
|
+
{new Date(preset.created_at).toLocaleDateString()}
|
|
457
|
+
</span>
|
|
458
|
+
</button>
|
|
459
|
+
{isMine && (
|
|
460
|
+
<DropdownMenu>
|
|
461
|
+
<DropdownMenuTrigger asChild>
|
|
462
|
+
<button
|
|
463
|
+
className={cn(
|
|
464
|
+
'flex h-6 w-6 shrink-0 items-center justify-center rounded-md opacity-0 transition-colors group-hover:opacity-100',
|
|
465
|
+
justOverwritten
|
|
466
|
+
? 'bg-green-500/10 text-green-400 opacity-100'
|
|
467
|
+
: 'text-muted-foreground hover:bg-white/10 hover:text-foreground',
|
|
468
|
+
)}
|
|
469
|
+
type="button"
|
|
470
|
+
>
|
|
471
|
+
{justOverwritten ? (
|
|
472
|
+
<Check className="h-3 w-3" />
|
|
473
|
+
) : (
|
|
474
|
+
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
475
|
+
)}
|
|
476
|
+
</button>
|
|
477
|
+
</DropdownMenuTrigger>
|
|
478
|
+
<DropdownMenuContent align="start" className="min-w-44" side="left">
|
|
479
|
+
<DropdownMenuItem onClick={onOverwrite}>
|
|
480
|
+
<Save className="h-3.5 w-3.5" />
|
|
481
|
+
Update with current
|
|
482
|
+
</DropdownMenuItem>
|
|
483
|
+
{showCommunityToggle && (
|
|
484
|
+
<DropdownMenuItem onClick={onToggleCommunity}>
|
|
485
|
+
{preset.is_community ? (
|
|
486
|
+
<>
|
|
487
|
+
<GlobeLock className="h-3.5 w-3.5" />
|
|
488
|
+
Remove from community
|
|
489
|
+
</>
|
|
490
|
+
) : (
|
|
491
|
+
<>
|
|
492
|
+
<Globe className="h-3.5 w-3.5" />
|
|
493
|
+
Share with community
|
|
494
|
+
</>
|
|
495
|
+
)}
|
|
496
|
+
</DropdownMenuItem>
|
|
497
|
+
)}
|
|
498
|
+
<DropdownMenuItem onClick={onStartRename}>
|
|
499
|
+
<Pencil className="h-3.5 w-3.5" />
|
|
500
|
+
Rename
|
|
501
|
+
</DropdownMenuItem>
|
|
502
|
+
<DropdownMenuItem onClick={onDeleteRequest} variant="destructive">
|
|
503
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
504
|
+
Delete
|
|
505
|
+
</DropdownMenuItem>
|
|
506
|
+
</DropdownMenuContent>
|
|
507
|
+
</DropdownMenu>
|
|
508
|
+
)}
|
|
509
|
+
</li>
|
|
510
|
+
)
|
|
511
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core'
|
|
4
|
+
import { Box, Image as ImageIcon } from 'lucide-react'
|
|
5
|
+
import { useCallback } from 'react'
|
|
6
|
+
import useEditor from '../../../store/use-editor'
|
|
7
|
+
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
8
|
+
import { MetricControl } from '../controls/metric-control'
|
|
9
|
+
import { PanelSection } from '../controls/panel-section'
|
|
10
|
+
import { SliderControl } from '../controls/slider-control'
|
|
11
|
+
import { PanelWrapper } from './panel-wrapper'
|
|
12
|
+
|
|
13
|
+
type ReferenceNode = ScanNode | GuideNode
|
|
14
|
+
|
|
15
|
+
export function ReferencePanel() {
|
|
16
|
+
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
|
17
|
+
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
|
|
18
|
+
const nodes = useScene((s) => s.nodes)
|
|
19
|
+
const updateNode = useScene((s) => s.updateNode)
|
|
20
|
+
|
|
21
|
+
const node = selectedReferenceId
|
|
22
|
+
? (nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
|
|
23
|
+
: undefined
|
|
24
|
+
|
|
25
|
+
const handleUpdate = useCallback(
|
|
26
|
+
(updates: Partial<ReferenceNode>) => {
|
|
27
|
+
if (!selectedReferenceId) return
|
|
28
|
+
updateNode(selectedReferenceId as AnyNode['id'], updates)
|
|
29
|
+
},
|
|
30
|
+
[selectedReferenceId, updateNode],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const handleClose = useCallback(() => {
|
|
34
|
+
setSelectedReferenceId(null)
|
|
35
|
+
}, [setSelectedReferenceId])
|
|
36
|
+
|
|
37
|
+
if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null
|
|
38
|
+
|
|
39
|
+
const isScan = node.type === 'scan'
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<PanelWrapper
|
|
43
|
+
icon={isScan ? undefined : undefined}
|
|
44
|
+
onClose={handleClose}
|
|
45
|
+
title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
|
|
46
|
+
width={300}
|
|
47
|
+
>
|
|
48
|
+
<PanelSection title="Position">
|
|
49
|
+
<SliderControl
|
|
50
|
+
label={
|
|
51
|
+
<>
|
|
52
|
+
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
|
53
|
+
</>
|
|
54
|
+
}
|
|
55
|
+
max={50}
|
|
56
|
+
min={-50}
|
|
57
|
+
onChange={(value) => {
|
|
58
|
+
const pos = [...node.position] as [number, number, number]
|
|
59
|
+
pos[0] = value
|
|
60
|
+
handleUpdate({ position: pos })
|
|
61
|
+
}}
|
|
62
|
+
precision={2}
|
|
63
|
+
step={0.1}
|
|
64
|
+
unit="m"
|
|
65
|
+
value={Math.round(node.position[0] * 100) / 100}
|
|
66
|
+
/>
|
|
67
|
+
<SliderControl
|
|
68
|
+
label={
|
|
69
|
+
<>
|
|
70
|
+
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
|
71
|
+
</>
|
|
72
|
+
}
|
|
73
|
+
max={50}
|
|
74
|
+
min={-50}
|
|
75
|
+
onChange={(value) => {
|
|
76
|
+
const pos = [...node.position] as [number, number, number]
|
|
77
|
+
pos[1] = value
|
|
78
|
+
handleUpdate({ position: pos })
|
|
79
|
+
}}
|
|
80
|
+
precision={2}
|
|
81
|
+
step={0.1}
|
|
82
|
+
unit="m"
|
|
83
|
+
value={Math.round(node.position[1] * 100) / 100}
|
|
84
|
+
/>
|
|
85
|
+
<SliderControl
|
|
86
|
+
label={
|
|
87
|
+
<>
|
|
88
|
+
Z<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
|
89
|
+
</>
|
|
90
|
+
}
|
|
91
|
+
max={50}
|
|
92
|
+
min={-50}
|
|
93
|
+
onChange={(value) => {
|
|
94
|
+
const pos = [...node.position] as [number, number, number]
|
|
95
|
+
pos[2] = value
|
|
96
|
+
handleUpdate({ position: pos })
|
|
97
|
+
}}
|
|
98
|
+
precision={2}
|
|
99
|
+
step={0.1}
|
|
100
|
+
unit="m"
|
|
101
|
+
value={Math.round(node.position[2] * 100) / 100}
|
|
102
|
+
/>
|
|
103
|
+
</PanelSection>
|
|
104
|
+
|
|
105
|
+
<PanelSection title="Rotation">
|
|
106
|
+
<SliderControl
|
|
107
|
+
label={
|
|
108
|
+
<>
|
|
109
|
+
Y<sub className="ml-[1px] text-[11px] opacity-70">rot</sub>
|
|
110
|
+
</>
|
|
111
|
+
}
|
|
112
|
+
max={180}
|
|
113
|
+
min={-180}
|
|
114
|
+
onChange={(degrees) => {
|
|
115
|
+
const radians = (degrees * Math.PI) / 180
|
|
116
|
+
handleUpdate({
|
|
117
|
+
rotation: [node.rotation[0], radians, node.rotation[2]],
|
|
118
|
+
})
|
|
119
|
+
}}
|
|
120
|
+
precision={0}
|
|
121
|
+
step={1}
|
|
122
|
+
unit="°"
|
|
123
|
+
value={Math.round((node.rotation[1] * 180) / Math.PI)}
|
|
124
|
+
/>
|
|
125
|
+
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
|
126
|
+
<ActionButton
|
|
127
|
+
label="-45°"
|
|
128
|
+
onClick={() =>
|
|
129
|
+
handleUpdate({
|
|
130
|
+
rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]],
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
/>
|
|
134
|
+
<ActionButton
|
|
135
|
+
label="+45°"
|
|
136
|
+
onClick={() =>
|
|
137
|
+
handleUpdate({
|
|
138
|
+
rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]],
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
</PanelSection>
|
|
144
|
+
|
|
145
|
+
<PanelSection title="Scale & Opacity">
|
|
146
|
+
<SliderControl
|
|
147
|
+
label={
|
|
148
|
+
<>
|
|
149
|
+
XYZ<sub className="ml-[1px] text-[11px] opacity-70">scale</sub>
|
|
150
|
+
</>
|
|
151
|
+
}
|
|
152
|
+
max={10}
|
|
153
|
+
min={0.01}
|
|
154
|
+
onChange={(value) => {
|
|
155
|
+
if (value > 0) {
|
|
156
|
+
handleUpdate({ scale: value })
|
|
157
|
+
}
|
|
158
|
+
}}
|
|
159
|
+
precision={2}
|
|
160
|
+
step={0.1}
|
|
161
|
+
value={Math.round(node.scale * 100) / 100}
|
|
162
|
+
/>
|
|
163
|
+
|
|
164
|
+
<SliderControl
|
|
165
|
+
label="Opacity"
|
|
166
|
+
max={100}
|
|
167
|
+
min={0}
|
|
168
|
+
onChange={(v) => handleUpdate({ opacity: v })}
|
|
169
|
+
precision={0}
|
|
170
|
+
step={1}
|
|
171
|
+
unit="%"
|
|
172
|
+
value={node.opacity}
|
|
173
|
+
/>
|
|
174
|
+
</PanelSection>
|
|
175
|
+
</PanelWrapper>
|
|
176
|
+
)
|
|
177
|
+
}
|