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:
Aymeric Rabot
2026-02-27 01:19:13 -05:00
co-authored by Claude Opus 4.6
parent 5ecefbdc06
commit d0e0fe7e1e
17 changed files with 569 additions and 353 deletions
+5 -5
View File
@@ -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')
+30 -35
View File
@@ -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()}>
+17 -8
View File
@@ -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)
<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}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{tool.label}
</p>
</TooltipContent>
</Tooltip>
// 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">
@@ -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>
)}
+11
View File
@@ -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