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
@@ -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">
@@ -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>
)}