Retires the door/window-only `PresetsPopover`, its `presets-context` adapter, and the `PresetThumbnailGenerator` ahead of the unified preset system landing via the items catalog. Drops the public exports (`PresetsPopover`, `PresetsAdapter`, `PresetsTab`, `PresetsProvider`, `usePresetsAdapter`) and the matching `presetsAdapter` prop / provider wrappers on `<Editor>`, plus the `preset:generate-thumbnail` / `preset:thumbnail-updated` event types in core. Door and window panels now render the regular parametric inspector with no popover trigger; `materialPreset` stays untouched. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
1fd59dd9cd
commit
0ad0ac1591
@@ -189,11 +189,6 @@ type WindowAnimationEvents = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type PresetEvents = {
|
|
||||||
'preset:generate-thumbnail': { presetId: string; nodeId: string }
|
|
||||||
'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
type ThumbnailEvents = {
|
type ThumbnailEvents = {
|
||||||
'thumbnail:before-capture': undefined
|
'thumbnail:before-capture': undefined
|
||||||
'thumbnail:after-capture': undefined
|
'thumbnail:after-capture': undefined
|
||||||
@@ -243,7 +238,6 @@ type EditorEvents = GridEvents &
|
|||||||
GuideEvents &
|
GuideEvents &
|
||||||
DoorAnimationEvents &
|
DoorAnimationEvents &
|
||||||
WindowAnimationEvents &
|
WindowAnimationEvents &
|
||||||
PresetEvents &
|
|
||||||
ThumbnailEvents &
|
ThumbnailEvents &
|
||||||
SnapshotEvents &
|
SnapshotEvents &
|
||||||
AIChatEvents
|
AIChatEvents
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import { ViewerOverlay } from '../../components/viewer-overlay'
|
import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||||
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
|
|
||||||
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
|
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
|
||||||
import { useKeyboard } from '../../hooks/use-keyboard'
|
import { useKeyboard } from '../../hooks/use-keyboard'
|
||||||
import {
|
import {
|
||||||
@@ -61,7 +60,6 @@ import { FloatingActionMenu } from './floating-action-menu'
|
|||||||
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
|
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
|
||||||
import { FloorplanPanel } from './floorplan-panel'
|
import { FloorplanPanel } from './floorplan-panel'
|
||||||
import { Grid } from './grid'
|
import { Grid } from './grid'
|
||||||
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
|
|
||||||
import { NodeArrowHandles } from './node-arrow-handles'
|
import { NodeArrowHandles } from './node-arrow-handles'
|
||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
import { SiteEdgeLabels } from './site-edge-labels'
|
import { SiteEdgeLabels } from './site-edge-labels'
|
||||||
@@ -155,9 +153,6 @@ export interface EditorProps {
|
|||||||
sitePanelProps?: SitePanelProps
|
sitePanelProps?: SitePanelProps
|
||||||
extraSidebarPanels?: ExtraPanel[]
|
extraSidebarPanels?: ExtraPanel[]
|
||||||
|
|
||||||
// Presets storage backend (defaults to localStorage)
|
|
||||||
presetsAdapter?: PresetsAdapter
|
|
||||||
|
|
||||||
// Command palette fallback when no commands match
|
// Command palette fallback when no commands match
|
||||||
commandPaletteEmptyAction?: CommandPaletteEmptyAction
|
commandPaletteEmptyAction?: CommandPaletteEmptyAction
|
||||||
}
|
}
|
||||||
@@ -577,9 +572,7 @@ function PaintCursorBadge({
|
|||||||
// grid lines when the user picks a finer snap (0.25 / 0.1 / 0.05).
|
// grid lines when the user picks a finer snap (0.25 / 0.1 / 0.05).
|
||||||
function SnapAwareGrid() {
|
function SnapAwareGrid() {
|
||||||
const gridSnapStep = useEditor((s) => s.gridSnapStep)
|
const gridSnapStep = useEditor((s) => s.gridSnapStep)
|
||||||
return (
|
return <Grid cellColor="#aaa" cellSize={gridSnapStep} fadeDistance={500} sectionColor="#ccc" />
|
||||||
<Grid cellColor="#aaa" cellSize={gridSnapStep} fadeDistance={500} sectionColor="#ccc" />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
|
// ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
|
||||||
@@ -615,7 +608,6 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
|||||||
{isFirstPersonMode && <FirstPersonControls />}
|
{isFirstPersonMode && <FirstPersonControls />}
|
||||||
<CustomCameraControls />
|
<CustomCameraControls />
|
||||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||||
<PresetThumbnailGenerator />
|
|
||||||
{!isFirstPersonMode && <SiteEdgeLabels />}
|
{!isFirstPersonMode && <SiteEdgeLabels />}
|
||||||
<InteractiveSystem />
|
<InteractiveSystem />
|
||||||
</>
|
</>
|
||||||
@@ -952,7 +944,6 @@ export default function Editor({
|
|||||||
settingsPanelProps,
|
settingsPanelProps,
|
||||||
sitePanelProps,
|
sitePanelProps,
|
||||||
extraSidebarPanels,
|
extraSidebarPanels,
|
||||||
presetsAdapter,
|
|
||||||
commandPaletteEmptyAction,
|
commandPaletteEmptyAction,
|
||||||
}: EditorProps) {
|
}: EditorProps) {
|
||||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||||
@@ -1098,7 +1089,6 @@ export default function Editor({
|
|||||||
<StairEditSystem />
|
<StairEditSystem />
|
||||||
<CustomCameraControls />
|
<CustomCameraControls />
|
||||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||||
<PresetThumbnailGenerator />
|
|
||||||
<InteractiveSystem />
|
<InteractiveSystem />
|
||||||
</Viewer>
|
</Viewer>
|
||||||
)
|
)
|
||||||
@@ -1142,7 +1132,7 @@ export default function Editor({
|
|||||||
})) ?? []
|
})) ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PresetsProvider adapter={presetsAdapter}>
|
<>
|
||||||
{showLoader && (
|
{showLoader && (
|
||||||
<div className="fixed inset-0 z-60">
|
<div className="fixed inset-0 z-60">
|
||||||
<SceneLoader />
|
<SceneLoader />
|
||||||
@@ -1196,7 +1186,7 @@ export default function Editor({
|
|||||||
<CommandPalette emptyAction={commandPaletteEmptyAction} />
|
<CommandPalette emptyAction={commandPaletteEmptyAction} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</PresetsProvider>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1207,7 +1197,6 @@ export default function Editor({
|
|||||||
const overlayLeft = LAYOUT_PADDING + (isSidebarCollapsed ? 8 : sidebarWidth) + LAYOUT_GAP
|
const overlayLeft = LAYOUT_PADDING + (isSidebarCollapsed ? 8 : sidebarWidth) + LAYOUT_GAP
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PresetsProvider adapter={presetsAdapter}>
|
|
||||||
<div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground">
|
<div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground">
|
||||||
{showLoader && (
|
{showLoader && (
|
||||||
<div className="fixed inset-0 z-60">
|
<div className="fixed inset-0 z-60">
|
||||||
@@ -1255,6 +1244,5 @@ export default function Editor({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PresetsProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { emitter, sceneRegistry } from '@pascal-app/core'
|
|
||||||
import { useThree } from '@react-three/fiber'
|
|
||||||
import { useCallback, useEffect } from 'react'
|
|
||||||
import * as THREE from 'three'
|
|
||||||
import { usePresetsAdapter } from '../../contexts/presets-context'
|
|
||||||
|
|
||||||
const THUMBNAIL_SIZE = 1080
|
|
||||||
const CAMERA_FOV = 45
|
|
||||||
|
|
||||||
export const PresetThumbnailGenerator = () => {
|
|
||||||
const gl = useThree((state) => state.gl)
|
|
||||||
const scene = useThree((state) => state.scene)
|
|
||||||
const adapter = usePresetsAdapter()
|
|
||||||
|
|
||||||
const generate = useCallback(
|
|
||||||
async ({ presetId, nodeId }: { presetId: string; nodeId: string }) => {
|
|
||||||
const target = sceneRegistry.nodes.get(nodeId)
|
|
||||||
if (!target) {
|
|
||||||
console.error('❌ PresetThumbnail: node not found', nodeId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute each mesh's transform relative to the target node (cancels world
|
|
||||||
// position/rotation), so the item is always rendered at origin with a known
|
|
||||||
// neutral orientation regardless of where it's placed in the scene.
|
|
||||||
target.updateWorldMatrix(true, true)
|
|
||||||
const targetInverse = new THREE.Matrix4().copy(target.matrixWorld).invert()
|
|
||||||
const relMatrix = new THREE.Matrix4()
|
|
||||||
|
|
||||||
const clones: THREE.Object3D[] = []
|
|
||||||
target.traverse((obj) => {
|
|
||||||
if (
|
|
||||||
!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
const c = obj.clone(false) // shallow clone: copies geometry, material, visible — no children
|
|
||||||
relMatrix.multiplyMatrices(targetInverse, obj.matrixWorld)
|
|
||||||
relMatrix.decompose(c.position, c.quaternion, c.scale)
|
|
||||||
scene.add(c)
|
|
||||||
clones.push(c)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (clones.length === 0) {
|
|
||||||
console.error('❌ PresetThumbnail: no renderable objects found', nodeId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combined bounding box across all clones
|
|
||||||
const box = new THREE.Box3()
|
|
||||||
for (const c of clones) box.expandByObject(c)
|
|
||||||
|
|
||||||
if (box.isEmpty()) {
|
|
||||||
for (const c of clones) scene.remove(c)
|
|
||||||
console.error('❌ PresetThumbnail: empty bounding box', nodeId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const sphere = new THREE.Sphere()
|
|
||||||
box.getBoundingSphere(sphere)
|
|
||||||
|
|
||||||
// Camera: aspect matches canvas (center-cropped to square after render)
|
|
||||||
const { width, height } = gl.domElement
|
|
||||||
const camera = new THREE.PerspectiveCamera(CAMERA_FOV, width / height, 0.01, 1000)
|
|
||||||
const dir = new THREE.Vector3(-0.5, 0.5, 0.5).normalize()
|
|
||||||
const fovRad = (CAMERA_FOV * Math.PI) / 180
|
|
||||||
const dist = (sphere.radius / Math.tan(fovRad / 2)) * 1.3
|
|
||||||
camera.position.copy(sphere.center).addScaledVector(dir, dist)
|
|
||||||
camera.lookAt(sphere.center)
|
|
||||||
camera.updateProjectionMatrix()
|
|
||||||
|
|
||||||
// Hide all scene geometry except the clones — leave lights, cameras, etc. intact
|
|
||||||
const cloneSet = new Set<THREE.Object3D>(clones)
|
|
||||||
const snapshot = new Map<THREE.Object3D, boolean>()
|
|
||||||
scene.traverse((obj) => {
|
|
||||||
if (cloneSet.has(obj)) return
|
|
||||||
if (
|
|
||||||
!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
snapshot.set(obj, obj.visible)
|
|
||||||
obj.visible = false
|
|
||||||
})
|
|
||||||
|
|
||||||
gl.render(scene, camera)
|
|
||||||
|
|
||||||
// Restore visibility and remove clones
|
|
||||||
snapshot.forEach((wasVisible, obj) => {
|
|
||||||
obj.visible = wasVisible
|
|
||||||
})
|
|
||||||
for (const c of clones) scene.remove(c)
|
|
||||||
|
|
||||||
// Center-crop to square and scale to THUMBNAIL_SIZE
|
|
||||||
const minDim = Math.min(width, height)
|
|
||||||
const sx = Math.round((width - minDim) / 2)
|
|
||||||
const sy = Math.round((height - minDim) / 2)
|
|
||||||
const offscreen = document.createElement('canvas')
|
|
||||||
offscreen.width = THUMBNAIL_SIZE
|
|
||||||
offscreen.height = THUMBNAIL_SIZE
|
|
||||||
const ctx = offscreen.getContext('2d')!
|
|
||||||
ctx.drawImage(gl.domElement, sx, sy, minDim, minDim, 0, 0, THUMBNAIL_SIZE, THUMBNAIL_SIZE)
|
|
||||||
|
|
||||||
offscreen.toBlob(async (blob) => {
|
|
||||||
if (!blob) {
|
|
||||||
console.error('❌ PresetThumbnail: failed to create blob')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!adapter.uploadPresetThumbnail) return
|
|
||||||
const thumbnailUrl = await adapter.uploadPresetThumbnail(presetId, blob)
|
|
||||||
if (thumbnailUrl) {
|
|
||||||
emitter.emit('preset:thumbnail-updated', { presetId, thumbnailUrl })
|
|
||||||
}
|
|
||||||
}, 'image/png')
|
|
||||||
},
|
|
||||||
[gl, scene, adapter],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
emitter.on('preset:generate-thumbnail', generate)
|
|
||||||
return () => emitter.off('preset:generate-thumbnail', generate)
|
|
||||||
}, [generate])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,511 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { emitter } from '@pascal-app/core'
|
|
||||||
import {
|
|
||||||
BookMarked,
|
|
||||||
Check,
|
|
||||||
Globe,
|
|
||||||
GlobeLock,
|
|
||||||
MoreHorizontal,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Save,
|
|
||||||
Trash2,
|
|
||||||
Users,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import type { PresetsTab } from '../../../../contexts/presets-context'
|
|
||||||
import { cn } from '../../../../lib/utils'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '../../primitives/dropdown-menu'
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover'
|
|
||||||
|
|
||||||
export type PresetType = 'door' | 'window'
|
|
||||||
|
|
||||||
export interface PresetData {
|
|
||||||
id: string
|
|
||||||
type: string
|
|
||||||
name: string
|
|
||||||
data: Record<string, unknown>
|
|
||||||
thumbnail_url: string | null
|
|
||||||
user_id: string | null
|
|
||||||
is_community: boolean
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PresetsPopoverProps {
|
|
||||||
type: PresetType
|
|
||||||
children: React.ReactNode
|
|
||||||
isAuthenticated?: boolean
|
|
||||||
tabs?: PresetsTab[]
|
|
||||||
onFetchPresets: (tab: PresetsTab) => Promise<PresetData[]>
|
|
||||||
onApply: (data: Record<string, unknown>) => void
|
|
||||||
onSave: (name: string) => Promise<void>
|
|
||||||
onOverwrite: (id: string) => Promise<void>
|
|
||||||
onRename: (id: string, name: string) => Promise<void>
|
|
||||||
onDelete: (id: string) => Promise<void>
|
|
||||||
onToggleCommunity?: (id: string, current: boolean) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PresetsPopover({
|
|
||||||
type,
|
|
||||||
onApply,
|
|
||||||
onSave,
|
|
||||||
onOverwrite,
|
|
||||||
onFetchPresets,
|
|
||||||
onRename,
|
|
||||||
onDelete,
|
|
||||||
onToggleCommunity,
|
|
||||||
children,
|
|
||||||
isAuthenticated = false,
|
|
||||||
tabs = ['community', 'mine'],
|
|
||||||
}: PresetsPopoverProps) {
|
|
||||||
const defaultTab = tabs[0] ?? 'mine'
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [tab, setTab] = useState<PresetsTab>(defaultTab)
|
|
||||||
const [presets, setPresets] = useState<PresetData[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const [showSaveInput, setShowSaveInput] = useState(false)
|
|
||||||
const [saveName, setSaveName] = useState('')
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
|
||||||
const [renameValue, setRenameValue] = useState('')
|
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
|
||||||
const [overwrittenId, setOverwrittenId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const fetchPresets = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const data = await onFetchPresets(tab)
|
|
||||||
setPresets(data)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [onFetchPresets, tab])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) fetchPresets()
|
|
||||||
}, [open, fetchPresets])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAuthenticated && tab === 'mine') setTab(defaultTab)
|
|
||||||
}, [isAuthenticated, tab, defaultTab])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = ({ presetId, thumbnailUrl }: { presetId: string; thumbnailUrl: string }) => {
|
|
||||||
setPresets((prev) =>
|
|
||||||
prev.map((p) => (p.id === presetId ? { ...p, thumbnail_url: thumbnailUrl } : p)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
emitter.on('preset:thumbnail-updated', handler)
|
|
||||||
return () => emitter.off('preset:thumbnail-updated', handler)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSaveNew = async () => {
|
|
||||||
if (!saveName.trim()) return
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
await onSave(saveName.trim())
|
|
||||||
setSaveName('')
|
|
||||||
setShowSaveInput(false)
|
|
||||||
if (tab === 'mine') fetchPresets()
|
|
||||||
else setTab('mine')
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRename = async (id: string) => {
|
|
||||||
if (!renameValue.trim()) return
|
|
||||||
await onRename(id, renameValue.trim())
|
|
||||||
setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, name: renameValue.trim() } : p)))
|
|
||||||
setRenamingId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
await onDelete(id)
|
|
||||||
setPresets((prev) => prev.filter((p) => p.id !== id))
|
|
||||||
setDeletingId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOverwrite = async (id: string) => {
|
|
||||||
await onOverwrite(id)
|
|
||||||
setOverwrittenId(id)
|
|
||||||
setTimeout(() => setOverwrittenId(null), 1500)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleCommunity = async (id: string, current: boolean) => {
|
|
||||||
if (!onToggleCommunity) return
|
|
||||||
await onToggleCommunity(id, current)
|
|
||||||
setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, is_community: !current } : p)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const showTabs = tabs.length > 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover onOpenChange={setOpen} open={open}>
|
|
||||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
align="start"
|
|
||||||
className="w-72 overflow-hidden rounded-xl border-border/50 bg-sidebar/95 p-0 shadow-2xl backdrop-blur-xl"
|
|
||||||
side="left"
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-border/50 border-b px-3 py-2.5">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<BookMarked className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
<span className="font-semibold text-foreground text-xs tracking-tight">
|
|
||||||
{type === 'door' ? 'Door' : 'Window'} Presets
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{isAuthenticated && (
|
|
||||||
<button
|
|
||||||
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"
|
|
||||||
onClick={() => {
|
|
||||||
setShowSaveInput((v) => !v)
|
|
||||||
setSaveName('')
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
Save new
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showSaveInput && (
|
|
||||||
<div className="flex items-center gap-1.5 border-border/50 border-b bg-white/5 px-3 py-2">
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
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"
|
|
||||||
onChange={(e) => setSaveName(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') handleSaveNew()
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setShowSaveInput(false)
|
|
||||||
setSaveName('')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Preset name…"
|
|
||||||
value={saveName}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
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"
|
|
||||||
disabled={!saveName.trim() || saving}
|
|
||||||
onClick={handleSaveNew}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10"
|
|
||||||
onClick={() => {
|
|
||||||
setShowSaveInput(false)
|
|
||||||
setSaveName('')
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showTabs && (
|
|
||||||
<div className="flex border-border/50 border-b">
|
|
||||||
{tabs.includes('community') && (
|
|
||||||
<TabButton active={tab === 'community'} onClick={() => setTab('community')}>
|
|
||||||
<Users className="h-3 w-3" />
|
|
||||||
Community
|
|
||||||
</TabButton>
|
|
||||||
)}
|
|
||||||
{tabs.includes('mine') && (
|
|
||||||
<TabButton
|
|
||||||
active={tab === 'mine'}
|
|
||||||
disabled={!isAuthenticated}
|
|
||||||
onClick={() => {
|
|
||||||
if (isAuthenticated) setTab('mine')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BookMarked className="h-3 w-3" />
|
|
||||||
My presets
|
|
||||||
</TabButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="no-scrollbar max-h-72 overflow-y-auto">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
|
||||||
</div>
|
|
||||||
) : presets.length === 0 ? (
|
|
||||||
<EmptyState isAuthenticated={isAuthenticated} tab={tab} />
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-border/30">
|
|
||||||
{presets.map((preset) => (
|
|
||||||
<PresetRow
|
|
||||||
deletingId={deletingId}
|
|
||||||
isMine={tab === 'mine'}
|
|
||||||
key={preset.id}
|
|
||||||
onApply={() => {
|
|
||||||
onApply(preset.data)
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
onDeleteCancel={() => setDeletingId(null)}
|
|
||||||
onDeleteConfirm={() => handleDelete(preset.id)}
|
|
||||||
onDeleteRequest={() => setDeletingId(preset.id)}
|
|
||||||
onOverwrite={() => handleOverwrite(preset.id)}
|
|
||||||
onRenameCancel={() => setRenamingId(null)}
|
|
||||||
onRenameChange={setRenameValue}
|
|
||||||
onRenameConfirm={() => handleRename(preset.id)}
|
|
||||||
onStartRename={() => {
|
|
||||||
setRenamingId(preset.id)
|
|
||||||
setRenameValue(preset.name)
|
|
||||||
}}
|
|
||||||
onToggleCommunity={() => handleToggleCommunity(preset.id, preset.is_community)}
|
|
||||||
overwrittenId={overwrittenId}
|
|
||||||
preset={preset}
|
|
||||||
renameValue={renameValue}
|
|
||||||
renamingId={renamingId}
|
|
||||||
showCommunityToggle={!!onToggleCommunity}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabButton({
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
active: boolean
|
|
||||||
onClick: () => void
|
|
||||||
disabled?: boolean
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'flex flex-1 items-center justify-center gap-1.5 py-2 font-medium text-[11px] transition-colors',
|
|
||||||
active
|
|
||||||
? '-mb-px border-primary border-b-2 text-foreground'
|
|
||||||
: 'text-muted-foreground hover:text-foreground',
|
|
||||||
disabled && 'cursor-not-allowed opacity-40',
|
|
||||||
)}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyState({ tab, isAuthenticated }: { tab: PresetsTab; isAuthenticated: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center">
|
|
||||||
<BookMarked className="h-6 w-6 text-muted-foreground/40" />
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
{tab === 'community'
|
|
||||||
? 'No community presets yet.'
|
|
||||||
: isAuthenticated
|
|
||||||
? 'No presets saved yet. Use "Save new" to save the current configuration.'
|
|
||||||
: 'Sign in to save and view your presets.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PresetRowProps {
|
|
||||||
preset: PresetData
|
|
||||||
isMine: boolean
|
|
||||||
showCommunityToggle: boolean
|
|
||||||
renamingId: string | null
|
|
||||||
renameValue: string
|
|
||||||
deletingId: string | null
|
|
||||||
overwrittenId: string | null
|
|
||||||
onApply: () => void
|
|
||||||
onOverwrite: () => void
|
|
||||||
onToggleCommunity: () => void
|
|
||||||
onStartRename: () => void
|
|
||||||
onRenameChange: (v: string) => void
|
|
||||||
onRenameConfirm: () => void
|
|
||||||
onRenameCancel: () => void
|
|
||||||
onDeleteRequest: () => void
|
|
||||||
onDeleteConfirm: () => void
|
|
||||||
onDeleteCancel: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function PresetRow({
|
|
||||||
preset,
|
|
||||||
isMine,
|
|
||||||
showCommunityToggle,
|
|
||||||
renamingId,
|
|
||||||
renameValue,
|
|
||||||
deletingId,
|
|
||||||
overwrittenId,
|
|
||||||
onApply,
|
|
||||||
onOverwrite,
|
|
||||||
onToggleCommunity,
|
|
||||||
onStartRename,
|
|
||||||
onRenameChange,
|
|
||||||
onRenameConfirm,
|
|
||||||
onRenameCancel,
|
|
||||||
onDeleteRequest,
|
|
||||||
onDeleteConfirm,
|
|
||||||
onDeleteCancel,
|
|
||||||
}: PresetRowProps) {
|
|
||||||
const isRenaming = renamingId === preset.id
|
|
||||||
const isDeleting = deletingId === preset.id
|
|
||||||
const justOverwritten = overwrittenId === preset.id
|
|
||||||
|
|
||||||
if (isDeleting) {
|
|
||||||
return (
|
|
||||||
<li className="flex items-center justify-between gap-2 bg-red-500/10 px-3 py-2.5">
|
|
||||||
<span className="truncate text-foreground/80 text-xs">Delete "{preset.name}"?</span>
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
<button
|
|
||||||
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"
|
|
||||||
onClick={onDeleteConfirm}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded-md px-2 py-0.5 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-white/10"
|
|
||||||
onClick={onDeleteCancel}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRenaming) {
|
|
||||||
return (
|
|
||||||
<li className="flex items-center gap-1.5 px-3 py-2">
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
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"
|
|
||||||
onChange={(e) => onRenameChange(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') onRenameConfirm()
|
|
||||||
if (e.key === 'Escape') onRenameCancel()
|
|
||||||
}}
|
|
||||||
value={renameValue}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 text-primary transition-colors hover:bg-primary/30"
|
|
||||||
onClick={onRenameConfirm}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10"
|
|
||||||
onClick={onRenameCancel}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className="group flex items-center gap-2 px-3 py-2.5 transition-colors hover:bg-white/5">
|
|
||||||
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-md border border-border/40 bg-white/5">
|
|
||||||
{preset.thumbnail_url ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
alt={preset.name}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
src={preset.thumbnail_url}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<div className="h-3 w-5 rounded-sm border border-muted-foreground/30" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button className="min-w-0 flex-1 text-left" onClick={onApply} type="button">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="block truncate font-medium text-foreground text-xs group-hover:text-foreground/90">
|
|
||||||
{preset.name}
|
|
||||||
</span>
|
|
||||||
{isMine && preset.is_community && (
|
|
||||||
<Globe className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="block text-[10px] text-muted-foreground/60">
|
|
||||||
{new Date(preset.created_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{isMine && (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-md opacity-0 transition-colors group-hover:opacity-100',
|
|
||||||
justOverwritten
|
|
||||||
? 'bg-green-500/10 text-green-400 opacity-100'
|
|
||||||
: 'text-muted-foreground hover:bg-white/10 hover:text-foreground',
|
|
||||||
)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{justOverwritten ? (
|
|
||||||
<Check className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="min-w-44" side="left">
|
|
||||||
<DropdownMenuItem onClick={onOverwrite}>
|
|
||||||
<Save className="h-3.5 w-3.5" />
|
|
||||||
Update with current
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{showCommunityToggle && (
|
|
||||||
<DropdownMenuItem onClick={onToggleCommunity}>
|
|
||||||
{preset.is_community ? (
|
|
||||||
<>
|
|
||||||
<GlobeLock className="h-3.5 w-3.5" />
|
|
||||||
Remove from community
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Globe className="h-3.5 w-3.5" />
|
|
||||||
Share with community
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem onClick={onStartRename}>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
Rename
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={onDeleteRequest} variant="destructive">
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { createContext, useContext } from 'react'
|
|
||||||
import type { PresetData, PresetType } from '../components/ui/panels/presets/presets-popover'
|
|
||||||
|
|
||||||
export type { PresetData, PresetType }
|
|
||||||
|
|
||||||
export type PresetsTab = 'community' | 'mine'
|
|
||||||
|
|
||||||
export interface PresetsAdapter {
|
|
||||||
/** Tabs to show. Default: both. Standalone passes ['mine']. */
|
|
||||||
tabs?: PresetsTab[]
|
|
||||||
isAuthenticated?: boolean
|
|
||||||
fetchPresets: (type: PresetType, tab: PresetsTab) => Promise<PresetData[]>
|
|
||||||
savePreset: (
|
|
||||||
type: PresetType,
|
|
||||||
name: string,
|
|
||||||
data: Record<string, unknown>,
|
|
||||||
) => Promise<string | null>
|
|
||||||
overwritePreset: (type: PresetType, id: string, data: Record<string, unknown>) => Promise<void>
|
|
||||||
renamePreset: (id: string, name: string) => Promise<void>
|
|
||||||
deletePreset: (id: string) => Promise<void>
|
|
||||||
togglePresetCommunity?: (id: string, current: boolean) => Promise<void>
|
|
||||||
uploadPresetThumbnail?: (presetId: string, blob: Blob) => Promise<string | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRESETS_KEY = (type: string) => `pascal-presets-${type}`
|
|
||||||
|
|
||||||
export const localStoragePresetsAdapter: PresetsAdapter = {
|
|
||||||
tabs: ['mine'],
|
|
||||||
isAuthenticated: true,
|
|
||||||
|
|
||||||
fetchPresets: async (type, tab) => {
|
|
||||||
if (tab === 'community') return []
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(PRESETS_KEY(type))
|
|
||||||
return raw ? (JSON.parse(raw) as PresetData[]) : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
savePreset: async (type, name, data) => {
|
|
||||||
try {
|
|
||||||
const id = Math.random().toString(36).slice(2, 10)
|
|
||||||
const raw = localStorage.getItem(PRESETS_KEY(type))
|
|
||||||
const presets: PresetData[] = raw ? JSON.parse(raw) : []
|
|
||||||
presets.push({
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
name,
|
|
||||||
data,
|
|
||||||
thumbnail_url: null,
|
|
||||||
user_id: null,
|
|
||||||
is_community: false,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
localStorage.setItem(PRESETS_KEY(type), JSON.stringify(presets))
|
|
||||||
return id
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
overwritePreset: async (type, id, data) => {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(PRESETS_KEY(type))
|
|
||||||
if (!raw) return
|
|
||||||
const presets: PresetData[] = JSON.parse(raw)
|
|
||||||
localStorage.setItem(
|
|
||||||
PRESETS_KEY(type),
|
|
||||||
JSON.stringify(presets.map((p) => (p.id === id ? { ...p, data } : p))),
|
|
||||||
)
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
|
|
||||||
renamePreset: async (id, name) => {
|
|
||||||
for (const type of ['door', 'window']) {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(PRESETS_KEY(type))
|
|
||||||
if (!raw) continue
|
|
||||||
const presets: PresetData[] = JSON.parse(raw)
|
|
||||||
localStorage.setItem(
|
|
||||||
PRESETS_KEY(type),
|
|
||||||
JSON.stringify(presets.map((p) => (p.id === id ? { ...p, name } : p))),
|
|
||||||
)
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
deletePreset: async (id) => {
|
|
||||||
for (const type of ['door', 'window']) {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(PRESETS_KEY(type))
|
|
||||||
if (!raw) continue
|
|
||||||
const presets: PresetData[] = JSON.parse(raw)
|
|
||||||
localStorage.setItem(PRESETS_KEY(type), JSON.stringify(presets.filter((p) => p.id !== id)))
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const PresetsContext = createContext<PresetsAdapter>(localStoragePresetsAdapter)
|
|
||||||
|
|
||||||
export function PresetsProvider({
|
|
||||||
adapter,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
adapter?: PresetsAdapter
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<PresetsContext.Provider value={adapter ?? localStoragePresetsAdapter}>
|
|
||||||
{children}
|
|
||||||
</PresetsContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePresetsAdapter(): PresetsAdapter {
|
|
||||||
return useContext(PresetsContext)
|
|
||||||
}
|
|
||||||
@@ -131,9 +131,6 @@ export { CollectionsPopover } from './components/ui/panels/collections/collectio
|
|||||||
// a kind-owned panel and need PanelWrapper for the chrome.
|
// a kind-owned panel and need PanelWrapper for the chrome.
|
||||||
export { PanelWrapper } from './components/ui/panels/panel-wrapper'
|
export { PanelWrapper } from './components/ui/panels/panel-wrapper'
|
||||||
export { ParametricInspector as Inspector } from './components/ui/panels/parametric-inspector'
|
export { ParametricInspector as Inspector } from './components/ui/panels/parametric-inspector'
|
||||||
// Presets popover — used by kind-owned door / window panels for their
|
|
||||||
// hardware / type / opening presets.
|
|
||||||
export { PresetsPopover } from './components/ui/panels/presets/presets-popover'
|
|
||||||
export { PALETTE_COLORS } from './components/ui/primitives/color-dot'
|
export { PALETTE_COLORS } from './components/ui/primitives/color-dot'
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -153,8 +150,6 @@ export {
|
|||||||
} from './components/ui/sidebar/panels/settings-panel'
|
} from './components/ui/sidebar/panels/settings-panel'
|
||||||
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
|
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
|
||||||
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
|
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
|
||||||
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
|
|
||||||
export { PresetsProvider, usePresetsAdapter } from './contexts/presets-context'
|
|
||||||
export type { SaveStatus } from './hooks/use-auto-save'
|
export type { SaveStatus } from './hooks/use-auto-save'
|
||||||
// useDragAction is the React-side glue for the registry's DragAction
|
// useDragAction is the React-side glue for the registry's DragAction
|
||||||
// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports)
|
// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports)
|
||||||
|
|||||||
@@ -1,29 +1,20 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import { type AnyNode, type AnyNodeId, DoorNode, useInteractive, useScene } from '@pascal-app/core'
|
||||||
type AnyNode,
|
|
||||||
type AnyNodeId,
|
|
||||||
DoorNode,
|
|
||||||
emitter,
|
|
||||||
useInteractive,
|
|
||||||
useScene,
|
|
||||||
} from '@pascal-app/core'
|
|
||||||
import {
|
import {
|
||||||
ActionButton,
|
ActionButton,
|
||||||
ActionGroup,
|
ActionGroup,
|
||||||
cn,
|
cn,
|
||||||
PanelSection,
|
PanelSection,
|
||||||
PanelWrapper,
|
PanelWrapper,
|
||||||
PresetsPopover,
|
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
SliderControl,
|
SliderControl,
|
||||||
ToggleControl,
|
ToggleControl,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
useEditor,
|
useEditor,
|
||||||
usePresetsAdapter,
|
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { BookMarked, Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
import { Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||||
import { useCallback, useRef } from 'react'
|
import { useCallback, useRef } from 'react'
|
||||||
|
|
||||||
const doorTypeOptions = [
|
const doorTypeOptions = [
|
||||||
@@ -151,8 +142,6 @@ export default function DoorPanel() {
|
|||||||
value: unknown
|
value: unknown
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const adapter = usePresetsAdapter()
|
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined,
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined,
|
||||||
)
|
)
|
||||||
@@ -312,69 +301,6 @@ export default function DoorPanel() {
|
|||||||
handleUpdate({ segments: updated })
|
handleUpdate({ segments: updated })
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDoorPresetData = useCallback(() => {
|
|
||||||
if (!node) return null
|
|
||||||
return {
|
|
||||||
doorCategory: node.doorCategory,
|
|
||||||
doorType: node.doorType,
|
|
||||||
leafCount: node.leafCount,
|
|
||||||
operationState: node.operationState,
|
|
||||||
slideDirection: node.slideDirection,
|
|
||||||
trackStyle: node.trackStyle,
|
|
||||||
garagePanelCount: node.garagePanelCount,
|
|
||||||
width: node.width,
|
|
||||||
height: node.height,
|
|
||||||
frameThickness: node.frameThickness,
|
|
||||||
frameDepth: node.frameDepth,
|
|
||||||
openingKind: node.openingKind,
|
|
||||||
openingShape: node.openingShape,
|
|
||||||
openingRadiusMode: node.openingRadiusMode ?? 'all',
|
|
||||||
openingTopRadii: node.openingTopRadii ?? [0.15, 0.15],
|
|
||||||
cornerRadius: node.cornerRadius,
|
|
||||||
archHeight: node.archHeight,
|
|
||||||
openingRevealRadius: node.openingRevealRadius,
|
|
||||||
contentPadding: node.contentPadding,
|
|
||||||
hingesSide: node.hingesSide,
|
|
||||||
swingDirection: node.swingDirection,
|
|
||||||
threshold: node.threshold,
|
|
||||||
thresholdHeight: node.thresholdHeight,
|
|
||||||
handle: node.handle,
|
|
||||||
handleHeight: node.handleHeight,
|
|
||||||
handleSide: node.handleSide,
|
|
||||||
doorCloser: node.doorCloser,
|
|
||||||
panicBar: node.panicBar,
|
|
||||||
panicBarHeight: node.panicBarHeight,
|
|
||||||
segments: node.segments,
|
|
||||||
}
|
|
||||||
}, [node])
|
|
||||||
|
|
||||||
const handleSavePreset = useCallback(
|
|
||||||
async (name: string) => {
|
|
||||||
const data = getDoorPresetData()
|
|
||||||
if (!(data && selectedId)) return
|
|
||||||
const presetId = await adapter.savePreset('door', name, data)
|
|
||||||
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
|
|
||||||
},
|
|
||||||
[getDoorPresetData, selectedId, adapter],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleOverwritePreset = useCallback(
|
|
||||||
async (id: string) => {
|
|
||||||
const data = getDoorPresetData()
|
|
||||||
if (!(data && selectedId)) return
|
|
||||||
await adapter.overwritePreset('door', id, data)
|
|
||||||
emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
|
|
||||||
},
|
|
||||||
[getDoorPresetData, selectedId, adapter],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleApplyPreset = useCallback(
|
|
||||||
(data: Record<string, unknown>) => {
|
|
||||||
handleUpdate(data as Partial<DoorNode>)
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!(node && node.type === 'door' && selectedId)) return null
|
if (!(node && node.type === 'door' && selectedId)) return null
|
||||||
|
|
||||||
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
|
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
|
||||||
@@ -594,27 +520,6 @@ export default function DoorPanel() {
|
|||||||
title={node.name || 'Door'}
|
title={node.name || 'Door'}
|
||||||
width={320}
|
width={320}
|
||||||
>
|
>
|
||||||
{/* Presets strip */}
|
|
||||||
<div className="border-border/30 border-b px-3 pt-2.5 pb-1.5">
|
|
||||||
<PresetsPopover
|
|
||||||
isAuthenticated={adapter.isAuthenticated}
|
|
||||||
onApply={handleApplyPreset}
|
|
||||||
onDelete={(id) => adapter.deletePreset(id)}
|
|
||||||
onFetchPresets={(tab) => adapter.fetchPresets('door', tab)}
|
|
||||||
onOverwrite={handleOverwritePreset}
|
|
||||||
onRename={(id, name) => adapter.renamePreset(id, name)}
|
|
||||||
onSave={handleSavePreset}
|
|
||||||
onToggleCommunity={adapter.togglePresetCommunity}
|
|
||||||
tabs={adapter.tabs}
|
|
||||||
type="door"
|
|
||||||
>
|
|
||||||
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 font-medium text-muted-foreground text-xs transition-colors hover:bg-[#3e3e3e] hover:text-foreground">
|
|
||||||
<BookMarked className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span>Presets</span>
|
|
||||||
</button>
|
|
||||||
</PresetsPopover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PanelSection title="Type">
|
<PanelSection title="Type">
|
||||||
<div className="flex flex-col gap-2 px-1 pb-1">
|
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
emitter,
|
|
||||||
useInteractive,
|
useInteractive,
|
||||||
useScene,
|
useScene,
|
||||||
WindowNode,
|
WindowNode,
|
||||||
@@ -14,16 +13,14 @@ import {
|
|||||||
cn,
|
cn,
|
||||||
PanelSection,
|
PanelSection,
|
||||||
PanelWrapper,
|
PanelWrapper,
|
||||||
PresetsPopover,
|
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
SliderControl,
|
SliderControl,
|
||||||
ToggleControl,
|
ToggleControl,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
useEditor,
|
useEditor,
|
||||||
usePresetsAdapter,
|
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||||
import { useCallback, useRef } from 'react'
|
import { useCallback, useRef } from 'react'
|
||||||
|
|
||||||
function isSameWindowValue(current: unknown, next: unknown): boolean {
|
function isSameWindowValue(current: unknown, next: unknown): boolean {
|
||||||
@@ -104,8 +101,6 @@ export default function WindowPanel() {
|
|||||||
value: unknown
|
value: unknown
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const adapter = usePresetsAdapter()
|
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined,
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined,
|
||||||
)
|
)
|
||||||
@@ -251,62 +246,6 @@ export default function WindowPanel() {
|
|||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [node, setMovingNode, setSelection])
|
}, [node, setMovingNode, setSelection])
|
||||||
|
|
||||||
const getWindowPresetData = useCallback(() => {
|
|
||||||
if (!node) return null
|
|
||||||
return {
|
|
||||||
width: node.width,
|
|
||||||
height: node.height,
|
|
||||||
windowType: node.windowType,
|
|
||||||
operationState: node.operationState,
|
|
||||||
awningDirection: node.awningDirection,
|
|
||||||
casementStyle: node.casementStyle,
|
|
||||||
hingesSide: node.hingesSide,
|
|
||||||
frameThickness: node.frameThickness,
|
|
||||||
frameDepth: node.frameDepth,
|
|
||||||
openingKind: node.openingKind,
|
|
||||||
openingShape: node.openingShape,
|
|
||||||
openingRadiusMode: node.openingRadiusMode ?? 'all',
|
|
||||||
openingCornerRadii: node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15],
|
|
||||||
cornerRadius: node.cornerRadius,
|
|
||||||
archHeight: node.archHeight,
|
|
||||||
openingRevealRadius: node.openingRevealRadius,
|
|
||||||
columnRatios: node.columnRatios,
|
|
||||||
rowRatios: node.rowRatios,
|
|
||||||
columnDividerThickness: node.columnDividerThickness,
|
|
||||||
rowDividerThickness: node.rowDividerThickness,
|
|
||||||
sill: node.sill,
|
|
||||||
sillDepth: node.sillDepth,
|
|
||||||
sillThickness: node.sillThickness,
|
|
||||||
}
|
|
||||||
}, [node])
|
|
||||||
|
|
||||||
const handleSavePreset = useCallback(
|
|
||||||
async (name: string) => {
|
|
||||||
const data = getWindowPresetData()
|
|
||||||
if (!(data && selectedId)) return
|
|
||||||
const presetId = await adapter.savePreset('window', name, data)
|
|
||||||
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
|
|
||||||
},
|
|
||||||
[getWindowPresetData, selectedId, adapter],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleOverwritePreset = useCallback(
|
|
||||||
async (id: string) => {
|
|
||||||
const data = getWindowPresetData()
|
|
||||||
if (!(data && selectedId)) return
|
|
||||||
await adapter.overwritePreset('window', id, data)
|
|
||||||
emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
|
|
||||||
},
|
|
||||||
[getWindowPresetData, selectedId, adapter],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleApplyPreset = useCallback(
|
|
||||||
(data: Record<string, unknown>) => {
|
|
||||||
handleUpdate(data as Partial<WindowNode>)
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!(node && node.type === 'window' && selectedId)) return null
|
if (!(node && node.type === 'window' && selectedId)) return null
|
||||||
|
|
||||||
const numCols = node.columnRatios.length
|
const numCols = node.columnRatios.length
|
||||||
@@ -447,27 +386,6 @@ export default function WindowPanel() {
|
|||||||
title={node.name || 'Window'}
|
title={node.name || 'Window'}
|
||||||
width={320}
|
width={320}
|
||||||
>
|
>
|
||||||
{/* Presets strip */}
|
|
||||||
<div className="border-border/30 border-b px-3 pt-2.5 pb-1.5">
|
|
||||||
<PresetsPopover
|
|
||||||
isAuthenticated={adapter.isAuthenticated}
|
|
||||||
onApply={handleApplyPreset}
|
|
||||||
onDelete={(id) => adapter.deletePreset(id)}
|
|
||||||
onFetchPresets={(tab) => adapter.fetchPresets('window', tab)}
|
|
||||||
onOverwrite={handleOverwritePreset}
|
|
||||||
onRename={(id, name) => adapter.renamePreset(id, name)}
|
|
||||||
onSave={handleSavePreset}
|
|
||||||
onToggleCommunity={adapter.togglePresetCommunity}
|
|
||||||
tabs={adapter.tabs}
|
|
||||||
type="window"
|
|
||||||
>
|
|
||||||
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 font-medium text-muted-foreground text-xs transition-colors hover:bg-[#3e3e3e] hover:text-foreground">
|
|
||||||
<BookMarked className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span>Presets</span>
|
|
||||||
</button>
|
|
||||||
</PresetsPopover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PanelSection title="Type">
|
<PanelSection title="Type">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user