UX polish round 4: action button component, keyboard shortcuts dialog, and build fixes
- Add reusable ActionButton component with tooltip and shortcut display - Add keyboard shortcuts dialog in settings panel - Fix ButtonProps import error (derive type from Button component) - Fix AnyNodeId type mismatch in tree-node-actions visibility toggle - Various UI refinements across action menu, sidebar, and editor Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5ecefbdc06
commit
d0e0fe7e1e
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||
import { Viewer, useViewer } from '@pascal-app/viewer'
|
||||
import { useViewer, Viewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { useProjectScene } from '@/features/community/lib/models/hooks'
|
||||
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||
import { useKeyboard } from '@/hooks/use-keyboard'
|
||||
import { initSFXBus } from '@/lib/sfx-bus'
|
||||
import useEditor from '@/store/use-editor'
|
||||
@@ -16,6 +17,7 @@ import { ActionMenu } from '../ui/action-menu'
|
||||
import { HelperManager } from '../ui/helpers/helper-manager'
|
||||
import { PanelManager } from '../ui/panels/panel-manager'
|
||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
||||
import { SceneLoader } from '../ui/scene-loader'
|
||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||
import { CustomCameraControls } from './custom-camera-controls'
|
||||
import { ExportManager } from './export-manager'
|
||||
@@ -23,8 +25,6 @@ import { FloatingActionMenu } from './floating-action-menu'
|
||||
import { Grid } from './grid'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { ThumbnailGenerator } from './thumbnail-generator'
|
||||
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||
import { SceneLoader } from '../ui/scene-loader'
|
||||
|
||||
// Load default scene initially (will be replaced when project loads)
|
||||
useScene.getState().loadScene()
|
||||
@@ -35,7 +35,7 @@ initSpaceDetectionSync(useScene, useEditor)
|
||||
const sceneNodes = useScene.getState().nodes as Record<string, any>
|
||||
const sceneRootIds = useScene.getState().rootNodeIds
|
||||
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
|
||||
const resolve = (child: any) => typeof child === 'string' ? sceneNodes[child] : child
|
||||
const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child)
|
||||
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
|
||||
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
|
||||
|
||||
@@ -48,7 +48,7 @@ if (firstBuilding && firstLevel) {
|
||||
})
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
|
||||
|
||||
// Auto-select the wall tool if the level is empty
|
||||
if (!firstLevel.children || firstLevel.children.length === 0) {
|
||||
useEditor.getState().setMode('build')
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { ImageIcon, MessageSquare, X } from 'lucide-react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import {
|
||||
createImageUploadUrls,
|
||||
submitFeedback,
|
||||
} from '@/features/community/lib/feedback/actions'
|
||||
import { ImageIcon, MessageSquare, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/primitives/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -15,7 +12,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/primitives/dialog'
|
||||
import { Button } from '@/components/ui/primitives/button'
|
||||
import { createImageUploadUrls, submitFeedback } from '@/features/community/lib/feedback/actions'
|
||||
|
||||
const MAX_IMAGES = 5
|
||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
||||
@@ -49,25 +46,24 @@ export function FeedbackDialog({ projectId: projectIdProp }: { projectId?: strin
|
||||
const handleClose = () => {
|
||||
if (isSubmitting) return
|
||||
setOpen(false)
|
||||
images.forEach((img) => URL.revokeObjectURL(img.url))
|
||||
images.forEach((img) => {
|
||||
URL.revokeObjectURL(img.url)
|
||||
})
|
||||
}
|
||||
|
||||
const addFiles = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
const incoming = Array.from(files).filter(
|
||||
(f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_SIZE,
|
||||
)
|
||||
setImages((prev) => {
|
||||
const remaining = MAX_IMAGES - prev.length
|
||||
const added = incoming.slice(0, remaining).map((file) => ({
|
||||
file,
|
||||
url: URL.createObjectURL(file),
|
||||
}))
|
||||
return [...prev, ...added]
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
const addFiles = useCallback((files: FileList | File[]) => {
|
||||
const incoming = Array.from(files).filter(
|
||||
(f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_SIZE,
|
||||
)
|
||||
setImages((prev) => {
|
||||
const remaining = MAX_IMAGES - prev.length
|
||||
const added = incoming.slice(0, remaining).map((file) => ({
|
||||
file,
|
||||
url: URL.createObjectURL(file),
|
||||
}))
|
||||
return [...prev, ...added]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setImages((prev) => {
|
||||
@@ -190,7 +186,7 @@ export function FeedbackDialog({ projectId: projectIdProp }: { projectId?: strin
|
||||
<>
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md hover:bg-accent/50 transition-colors"
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Feedback
|
||||
@@ -249,11 +245,7 @@ export function FeedbackDialog({ projectId: projectIdProp }: { projectId?: strin
|
||||
key={img.url}
|
||||
className="group relative h-14 w-14 overflow-hidden rounded-md border border-border"
|
||||
>
|
||||
<img
|
||||
src={img.url}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
@@ -277,9 +269,7 @@ export function FeedbackDialog({ projectId: projectIdProp }: { projectId?: strin
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40"
|
||||
>
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
{images.length > 0
|
||||
? `${images.length}/${MAX_IMAGES}`
|
||||
: 'Attach'}
|
||||
{images.length > 0 ? `${images.length}/${MAX_IMAGES}` : 'Attach'}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -294,7 +284,12 @@ export function FeedbackDialog({ projectId: projectIdProp }: { projectId?: strin
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !message.trim()}>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Howl } from 'howler'
|
||||
import { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { cn } from '@/lib/utils'
|
||||
import useAudio from '@/store/use-audio'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
const PLAYLIST = [
|
||||
{
|
||||
@@ -170,8 +170,8 @@ export function PascalRadio() {
|
||||
}}
|
||||
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
|
||||
className={cn(
|
||||
"flex flex-col rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md overflow-hidden",
|
||||
!isOpen && "cursor-pointer hover:bg-accent/30 transition-colors"
|
||||
'flex flex-col rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md overflow-hidden',
|
||||
!isOpen && 'cursor-pointer hover:bg-accent/90 transition-colors',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 text-sm font-medium">
|
||||
@@ -197,7 +197,11 @@ export function PascalRadio() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isRadioPlaying ? <Volume2 className="h-3.5 w-3.5" /> : <VolumeX className="h-3.5 w-3.5" />}
|
||||
{isRadioPlaying ? (
|
||||
<Volume2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<VolumeX className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -205,8 +209,8 @@ export function PascalRadio() {
|
||||
setIsOpen(!isOpen)
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-sm p-1 transition-all cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
||||
isOpen && "bg-accent text-accent-foreground"
|
||||
'rounded-sm p-1 transition-all cursor-pointer hover:bg-accent hover:text-accent-foreground',
|
||||
isOpen && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
aria-label="Radio Settings"
|
||||
>
|
||||
@@ -236,7 +240,10 @@ export function PascalRadio() {
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</button>
|
||||
<p className="text-sm font-medium text-center flex-1 truncate" title={currentTrack.title}>
|
||||
<p
|
||||
className="text-sm font-medium text-center flex-1 truncate"
|
||||
title={currentTrack.title}
|
||||
>
|
||||
{currentTrack.title}
|
||||
</p>
|
||||
<button
|
||||
@@ -260,7 +267,9 @@ export function PascalRadio() {
|
||||
className="flex-1"
|
||||
aria-label="Radio Volume"
|
||||
/>
|
||||
<span className="w-8 text-right text-xs text-muted-foreground shrink-0">{radioVolume}%</span>
|
||||
<span className="w-8 text-right text-xs text-muted-foreground shrink-0">
|
||||
{radioVolume}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/primitives/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/primitives/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ActionButtonProps extends React.ComponentProps<typeof Button> {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
isActive?: boolean;
|
||||
tooltipContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
|
||||
(
|
||||
{ className, children, label, shortcut, isActive, tooltipContent, ...props },
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-11 w-11 transition-all",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center transition-transform",
|
||||
shortcut && "-translate-x-0.5 -translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{shortcut && (
|
||||
<div className="absolute bottom-1 right-1 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
|
||||
<span className="block font-mono text-[9px] font-medium leading-none text-muted-foreground/70">
|
||||
{shortcut}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{tooltipContent || (
|
||||
<p>
|
||||
{label} {shortcut && `(${shortcut})`}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
);
|
||||
ActionButton.displayName = "ActionButton";
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
import { emitter } from '@pascal-app/core'
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui/primitives/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/primitives/tooltip'
|
||||
import { ActionButton } from "./action-button";
|
||||
|
||||
export function CameraActions() {
|
||||
const goToTopView = () => {
|
||||
@@ -25,73 +20,55 @@ export function CameraActions() {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Orbit CCW */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="group h-9 w-9 text-muted-foreground transition-all hover:bg-white/5"
|
||||
onClick={orbitCCW}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Image
|
||||
alt="Orbit Left"
|
||||
className="h-[30px] w-[30px] object-contain opacity-70 transition-opacity group-hover:opacity-100 -scale-x-100"
|
||||
height={30}
|
||||
src="/icons/rotate.png"
|
||||
width={30}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Orbit Left</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ActionButton
|
||||
label="Orbit Left"
|
||||
className="group hover:bg-white/5"
|
||||
onClick={orbitCCW}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Image
|
||||
alt="Orbit Left"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100 -scale-x-100"
|
||||
height={28}
|
||||
src="/icons/rotate.png"
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
{/* Orbit CW */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="group h-9 w-9 text-muted-foreground transition-all hover:bg-white/5"
|
||||
onClick={orbitCW}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Image
|
||||
alt="Orbit Right"
|
||||
className="h-[30px] w-[30px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={30}
|
||||
src="/icons/rotate.png"
|
||||
width={30}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Orbit Right</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ActionButton
|
||||
label="Orbit Right"
|
||||
className="group hover:bg-white/5"
|
||||
onClick={orbitCW}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Image
|
||||
alt="Orbit Right"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={28}
|
||||
src="/icons/rotate.png"
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
{/* Top View */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="group h-9 w-9 text-muted-foreground transition-all hover:bg-white/5"
|
||||
onClick={goToTopView}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Image
|
||||
alt="Top View"
|
||||
className="h-[30px] w-[30px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={30}
|
||||
src="/icons/topview.png"
|
||||
width={30}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Top View</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ActionButton
|
||||
label="Top View"
|
||||
className="group hover:bg-white/5"
|
||||
onClick={goToTopView}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Image
|
||||
alt="Top View"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={28}
|
||||
src="/icons/topview.png"
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Button } from "@/components/ui/primitives/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/primitives/tooltip";
|
||||
import { ActionButton } from "./action-button";
|
||||
import { Pencil, Trash2, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -103,44 +98,37 @@ export function ControlModes() {
|
||||
const isImageMode = Boolean(m.imageSrc);
|
||||
|
||||
return (
|
||||
<Tooltip key={m.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
<ActionButton
|
||||
key={m.id}
|
||||
label={m.label}
|
||||
shortcut={m.shortcut}
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
!isImageMode && !isActive && m.color,
|
||||
!isImageMode && isActive && m.activeColor,
|
||||
isImageMode && isActive && "bg-white/10 hover:bg-white/10",
|
||||
isImageMode && !isActive && "hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => handleModeClick(m.id)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{m.imageSrc ? (
|
||||
<Image
|
||||
alt={m.label}
|
||||
className={cn(
|
||||
"h-9 w-9 transition-all",
|
||||
"text-muted-foreground",
|
||||
!isImageMode && !isActive && m.color,
|
||||
!isImageMode && isActive && m.activeColor,
|
||||
isImageMode && isActive && "bg-white/10 hover:bg-white/10",
|
||||
isImageMode && !isActive && "hover:bg-white/5"
|
||||
"h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200",
|
||||
!isActive && "opacity-60 grayscale",
|
||||
isActive && "opacity-100 grayscale-0"
|
||||
)}
|
||||
onClick={() => handleModeClick(m.id)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{m.imageSrc ? (
|
||||
<Image
|
||||
alt={m.label}
|
||||
className={cn(
|
||||
"h-[26px] w-[26px] object-contain transition-[opacity,filter] duration-200",
|
||||
!isActive && "opacity-60 grayscale",
|
||||
isActive && "opacity-100 grayscale-0"
|
||||
)}
|
||||
height={26}
|
||||
src={m.imageSrc}
|
||||
width={26}
|
||||
/>
|
||||
) : (
|
||||
Icon && <Icon className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{m.label} ({m.shortcut})
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
height={28}
|
||||
src={m.imageSrc}
|
||||
width={28}
|
||||
/>
|
||||
) : (
|
||||
Icon && <Icon className="h-5 w-5" />
|
||||
)}
|
||||
</ActionButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import { Button } from "@/components/ui/primitives/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/primitives/tooltip";
|
||||
import { ActionButton } from "./action-button";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import useEditor, { CatalogCategory } from "@/store/use-editor";
|
||||
@@ -76,40 +71,33 @@ export function FurnishTools() {
|
||||
catalogCategory === tool.catalogCategory;
|
||||
|
||||
return (
|
||||
<Tooltip key={`${tool.id}-${tool.catalogCategory ?? index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
"size-11 rounded-lg transition-all duration-300",
|
||||
isActive ? "bg-black/40 hover:bg-black/40 scale-110 z-10" : "bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isActive) {
|
||||
setCatalogCategory(tool.catalogCategory);
|
||||
setActiveTool("item");
|
||||
if (mode !== "build") {
|
||||
setMode("build");
|
||||
}
|
||||
}
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<NextImage
|
||||
alt={tool.label}
|
||||
className="size-full object-contain"
|
||||
height={28}
|
||||
src={tool.iconSrc}
|
||||
width={28}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{tool.label}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ActionButton
|
||||
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
||||
label={tool.label}
|
||||
className={cn(
|
||||
"rounded-lg duration-300",
|
||||
isActive ? "bg-black/40 hover:bg-black/40 scale-110 z-10" : "bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isActive) {
|
||||
setCatalogCategory(tool.catalogCategory);
|
||||
setActiveTool("item");
|
||||
if (mode !== "build") {
|
||||
setMode("build");
|
||||
}
|
||||
}
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<NextImage
|
||||
alt={tool.label}
|
||||
className="size-full object-contain"
|
||||
height={28}
|
||||
src={tool.iconSrc}
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import NextImage from 'next/image'
|
||||
import { Button } from '@/components/ui/primitives/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/primitives/tooltip'
|
||||
import { ActionButton } from "./action-button";
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import useEditor, { CatalogCategory, StructureTool, Tool } from '@/store/use-editor'
|
||||
@@ -53,42 +52,35 @@ export function StructureTools() {
|
||||
const isContextual = contextualTools.includes(tool.id)
|
||||
|
||||
return (
|
||||
<Tooltip key={`${tool.id}-${tool.catalogCategory ?? index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
'size-11 rounded-lg transition-all duration-300',
|
||||
isActive ? 'bg-black/40 hover:bg-black/40 scale-110 z-10' : 'bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isActive) {
|
||||
setTool(tool.id)
|
||||
setCatalogCategory(tool.catalogCategory ?? null)
|
||||
|
||||
// Automatically switch to build mode if we select a tool
|
||||
if (useEditor.getState().mode !== 'build') {
|
||||
useEditor.getState().setMode('build')
|
||||
}
|
||||
}
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<NextImage
|
||||
alt={tool.label}
|
||||
className="size-full object-contain"
|
||||
height={28}
|
||||
src={tool.iconSrc}
|
||||
width={28}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{tool.label}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ActionButton
|
||||
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
||||
label={tool.label}
|
||||
className={cn(
|
||||
'rounded-lg duration-300',
|
||||
isActive ? 'bg-black/40 hover:bg-black/40 scale-110 z-10' : 'bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isActive) {
|
||||
setTool(tool.id)
|
||||
setCatalogCategory(tool.catalogCategory ?? null)
|
||||
|
||||
// Automatically switch to build mode if we select a tool
|
||||
if (useEditor.getState().mode !== 'build') {
|
||||
useEditor.getState().setMode('build')
|
||||
}
|
||||
}
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<NextImage
|
||||
alt={tool.label}
|
||||
className="size-full object-contain"
|
||||
height={28}
|
||||
src={tool.iconSrc}
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Box, Camera, Diamond, Image, Layers, Layers2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/primitives/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/primitives/tooltip'
|
||||
import { ActionButton } from "./action-button";
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
||||
@@ -81,119 +76,87 @@ export function ViewToggles() {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Camera Mode */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
'h-9 w-9 text-muted-foreground transition-all',
|
||||
cameraMode === 'orthographic'
|
||||
? 'bg-violet-500/20 text-violet-400'
|
||||
: 'hover:text-violet-400',
|
||||
)}
|
||||
onClick={toggleCameraMode}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Camera className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Camera: {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ActionButton
|
||||
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
|
||||
className={cn(
|
||||
cameraMode === 'orthographic'
|
||||
? 'bg-violet-500/20 text-violet-400'
|
||||
: 'hover:text-violet-400',
|
||||
)}
|
||||
onClick={toggleCameraMode}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Camera className="h-6 w-6" />
|
||||
</ActionButton>
|
||||
|
||||
{/* Level Mode */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
'h-9 w-9 text-muted-foreground transition-all',
|
||||
levelMode !== 'stacked'
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
: 'hover:text-amber-400',
|
||||
)}
|
||||
onClick={cycleLevelMode}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{levelMode === 'solo' && <Diamond className="h-5 w-5" />}
|
||||
{levelMode === 'exploded' && <Layers2 className="h-5 w-5" />}
|
||||
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-5 w-5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Levels: {levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ActionButton
|
||||
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
|
||||
className={cn(
|
||||
levelMode !== 'stacked'
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
: 'hover:text-amber-400',
|
||||
)}
|
||||
onClick={cycleLevelMode}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
|
||||
{levelMode === 'exploded' && <Layers2 className="h-6 w-6" />}
|
||||
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-6 w-6" />}
|
||||
</ActionButton>
|
||||
|
||||
{/* Wall Mode */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
'h-9 w-9 text-muted-foreground transition-all p-0',
|
||||
wallMode !== 'cutaway'
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
)}
|
||||
onClick={cycleWallMode}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{(() => {
|
||||
const Icon = wallModeConfig[wallMode].icon
|
||||
return <Icon className="h-[26px] w-[26px]" />
|
||||
})()}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Walls: {wallModeConfig[wallMode].label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ActionButton
|
||||
label={`Walls: ${wallModeConfig[wallMode].label}`}
|
||||
className={cn(
|
||||
'p-0',
|
||||
wallMode !== 'cutaway'
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
)}
|
||||
onClick={cycleWallMode}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{(() => {
|
||||
const Icon = wallModeConfig[wallMode].icon
|
||||
return <Icon className="h-[28px] w-[28px]" />
|
||||
})()}
|
||||
</ActionButton>
|
||||
|
||||
{/* Show Scans */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
'h-9 w-9 text-muted-foreground transition-all p-0',
|
||||
showScans
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
)}
|
||||
onClick={() => setShowScans(!showScans)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<img alt="Scans" className="h-[26px] w-[26px] object-contain" src="/icons/mesh.png" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Scans: {showScans ? 'Visible' : 'Hidden'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ActionButton
|
||||
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
|
||||
className={cn(
|
||||
'p-0',
|
||||
showScans
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
)}
|
||||
onClick={() => setShowScans(!showScans)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
|
||||
</ActionButton>
|
||||
|
||||
{/* Show Guides */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
'h-9 w-9 text-muted-foreground transition-all p-0',
|
||||
showGuides
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
)}
|
||||
onClick={() => setShowGuides(!showGuides)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<img alt="Guides" className="h-[26px] w-[26px] object-contain" src="/icons/floorplan.png" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Guides: {showGuides ? 'Visible' : 'Hidden'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ActionButton
|
||||
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
||||
className={cn(
|
||||
'p-0',
|
||||
showGuides
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
)}
|
||||
onClick={() => setShowGuides(!showGuides)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<img alt="Guides" className="h-[28px] w-[28px] object-contain" src="/icons/floorplan.png" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { Switch } from "@/components/ui/primitives/switch";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { AudioSettingsDialog } from "./audio-settings-dialog";
|
||||
import { KeyboardShortcutsDialog } from "./keyboard-shortcuts-dialog";
|
||||
import { useProjectStore } from "@/features/community/lib/projects/store";
|
||||
import { updateProjectVisibility } from "@/features/community/lib/projects/actions";
|
||||
|
||||
@@ -395,6 +396,14 @@ export function SettingsPanel() {
|
||||
<AudioSettingsDialog />
|
||||
</div>
|
||||
|
||||
{/* Keyboard Section */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">
|
||||
Keyboard
|
||||
</label>
|
||||
<KeyboardShortcutsDialog />
|
||||
</div>
|
||||
|
||||
{/* Scene Graph */}
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { Keyboard } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/primitives/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/primitives/dialog";
|
||||
|
||||
type Shortcut = {
|
||||
keys: string[];
|
||||
action: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
type ShortcutCategory = {
|
||||
title: string;
|
||||
shortcuts: Shortcut[];
|
||||
};
|
||||
|
||||
const KEY_DISPLAY_MAP: Record<string, string> = {
|
||||
"Arrow Up": "↑",
|
||||
"Arrow Down": "↓",
|
||||
Esc: "⎋",
|
||||
Shift: "⇧",
|
||||
Space: "␣",
|
||||
};
|
||||
|
||||
const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
{
|
||||
title: "Editor Navigation",
|
||||
shortcuts: [
|
||||
{ keys: ["1"], action: "Switch to Site phase" },
|
||||
{ keys: ["2"], action: "Switch to Structure phase" },
|
||||
{ keys: ["3"], action: "Switch to Furnish phase" },
|
||||
{ keys: ["S"], action: "Switch to Structure layer" },
|
||||
{ keys: ["F"], action: "Switch to Furnish layer" },
|
||||
{ keys: ["Z"], action: "Switch to Zones layer" },
|
||||
{
|
||||
keys: ["Cmd/Ctrl", "Arrow Up"],
|
||||
action: "Select next level in the active building",
|
||||
},
|
||||
{
|
||||
keys: ["Cmd/Ctrl", "Arrow Down"],
|
||||
action: "Select previous level in the active building",
|
||||
},
|
||||
{ keys: ["Cmd/Ctrl", "B"], action: "Toggle sidebar" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Modes & History",
|
||||
shortcuts: [
|
||||
{ keys: ["V"], action: "Switch to Select mode" },
|
||||
{ keys: ["B"], action: "Switch to Build mode" },
|
||||
{
|
||||
keys: ["Esc"],
|
||||
action: "Cancel active tool, clear selection, and exit build mode",
|
||||
},
|
||||
{ keys: ["Delete / Backspace"], action: "Delete selected objects" },
|
||||
{ keys: ["Cmd/Ctrl", "Z"], action: "Undo" },
|
||||
{ keys: ["Cmd/Ctrl", "Shift", "Z"], action: "Redo" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Selection",
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ["Cmd/Ctrl", "Click"],
|
||||
action: "Add or remove an object from multi-selection",
|
||||
note: "Works while in Select mode.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Drawing Tools",
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ["Shift"],
|
||||
action: "Temporarily disable angle snapping while drawing walls, slabs, and ceilings",
|
||||
note: "Hold while drawing.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Item Placement",
|
||||
shortcuts: [
|
||||
{ keys: ["R"], action: "Rotate item clockwise by 90 degrees" },
|
||||
{ keys: ["T"], action: "Rotate item counter-clockwise by 90 degrees" },
|
||||
{
|
||||
keys: ["Shift"],
|
||||
action: "Temporarily bypass placement validation constraints",
|
||||
note: "Hold while placing.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Camera",
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ["Space", "Drag"],
|
||||
action: "Pan camera",
|
||||
note: "Hold Space while dragging with the mouse.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function getDisplayKey(key: string, isMac: boolean): string {
|
||||
if (key === "Cmd/Ctrl") return isMac ? "⌘" : "Ctrl";
|
||||
if (key === "Delete / Backspace") return isMac ? "⌫" : "Backspace";
|
||||
return KEY_DISPLAY_MAP[key] ?? key;
|
||||
}
|
||||
|
||||
function ShortcutKeys({ keys }: { keys: string[] }) {
|
||||
const [isMac, setIsMac] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMac(navigator.platform.toUpperCase().indexOf("MAC") >= 0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{keys.map((key, index) => (
|
||||
<div key={`${key}-${index}`} className="flex items-center gap-1">
|
||||
{index > 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground">+</span>
|
||||
) : null}
|
||||
<kbd
|
||||
className="inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-mono text-[11px] font-medium text-muted-foreground"
|
||||
title={key}
|
||||
>
|
||||
{getDisplayKey(key, isMac)}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full justify-start gap-2" variant="outline">
|
||||
<Keyboard className="size-4" />
|
||||
Keyboard Shortcuts
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] flex flex-col overflow-hidden p-0 sm:max-w-3xl">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||
<DialogDescription>
|
||||
Shortcuts are context-aware and depend on the current phase or tool.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
|
||||
{SHORTCUT_CATEGORIES.map((category) => (
|
||||
<section key={category.title} className="space-y-2">
|
||||
<h3 className="font-medium text-sm">{category.title}</h3>
|
||||
<div className="overflow-hidden rounded-md border border-border/80">
|
||||
{category.shortcuts.map((shortcut, index) => (
|
||||
<div
|
||||
key={`${category.title}-${shortcut.action}`}
|
||||
className="grid grid-cols-[minmax(130px,220px)_1fr] gap-3 px-3 py-2"
|
||||
>
|
||||
<ShortcutKeys keys={shortcut.keys} />
|
||||
<div>
|
||||
<p className="text-sm">{shortcut.action}</p>
|
||||
{shortcut.note ? (
|
||||
<p className="text-muted-foreground text-xs">{shortcut.note}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{index < category.shortcuts.length - 1 ? (
|
||||
<div className="col-span-2 border-border/60 border-b" />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -817,6 +817,11 @@ function LayerToggle() {
|
||||
/>
|
||||
Structure
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1.5 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md z-10">
|
||||
<span className="block font-mono text-[9px] font-medium leading-none text-muted-foreground/70">
|
||||
S
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -845,6 +850,11 @@ function LayerToggle() {
|
||||
/>
|
||||
Furnish
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1.5 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md z-10">
|
||||
<span className="block font-mono text-[9px] font-medium leading-none text-muted-foreground/70">
|
||||
F
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -874,6 +884,11 @@ function LayerToggle() {
|
||||
/>
|
||||
Zones
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1.5 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md z-10">
|
||||
<span className="block font-mono text-[9px] font-medium leading-none text-muted-foreground/70">
|
||||
Z
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type AnyNode, emitter, useScene } from "@pascal-app/core";
|
||||
import { type AnyNode, type AnyNodeId, emitter, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { Camera, Eye, EyeOff, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
@@ -14,12 +15,24 @@ interface TreeNodeActionsProps {
|
||||
export function TreeNodeActions({ node }: TreeNodeActionsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const updateNode = useScene((state) => state.updateNode);
|
||||
const updateNodes = useScene((state) => state.updateNodes);
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const hasCamera = !!node.camera;
|
||||
const isVisible = node.visible !== false;
|
||||
|
||||
const toggleVisibility = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
updateNode(node.id, { visible: !isVisible });
|
||||
const newVisibility = !isVisible;
|
||||
if (selectedIds && selectedIds.includes(node.id)) {
|
||||
updateNodes(
|
||||
selectedIds.map((id) => ({
|
||||
id: id as AnyNodeId,
|
||||
data: { visible: newVisibility },
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
updateNode(node.id, { visible: newVisibility });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCaptureCamera = (e: React.MouseEvent) => {
|
||||
|
||||
@@ -212,12 +212,18 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0 truncate">
|
||||
<div className={cn(
|
||||
"flex-1 min-w-0 truncate",
|
||||
!isVisible && "line-through text-muted-foreground"
|
||||
)}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="opacity-0 group-hover/row:opacity-100 pr-1">
|
||||
<div className={cn(
|
||||
"opacity-0 group-hover/row:opacity-100 pr-1 transition-opacity duration-200",
|
||||
!isVisible && "opacity-100"
|
||||
)}>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -37,6 +37,17 @@ export const useKeyboard = () => {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('furnish')
|
||||
useEditor.getState().setMode('select')
|
||||
} else if (e.key === 's' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
} else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('furnish')
|
||||
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('zones')
|
||||
}
|
||||
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Reference in New Issue
Block a user