Aymeric Rabot d0e0fe7e1e 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>
2026-02-27 01:19:13 -05:00

120 lines
4.2 KiB
TypeScript

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 {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/primitives/popover";
interface TreeNodeActionsProps {
node: AnyNode;
}
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();
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) => {
e.stopPropagation();
emitter.emit("camera-controls:capture", { nodeId: node.id });
setOpen(false);
};
const handleViewCamera = (e: React.MouseEvent) => {
e.stopPropagation();
emitter.emit("camera-controls:view", { nodeId: node.id });
setOpen(false);
};
const handleClearCamera = (e: React.MouseEvent) => {
e.stopPropagation();
updateNode(node.id, { camera: undefined });
setOpen(false);
};
return (
<div className="flex items-center gap-0.5">
<button
className="w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
onClick={toggleVisibility}
title={isVisible ? "Hide" : "Show"}
>
{isVisible ? (
<Eye className="w-3 h-3" />
) : (
<EyeOff className="w-3 h-3 opacity-50" />
)}
</button>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className="relative w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => e.stopPropagation()}
title="Camera snapshot"
>
<Camera className="w-3 h-3" />
{hasCamera && (
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
)}
</button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
className="w-auto p-1"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-0.5">
{hasCamera && (
<button
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
onClick={handleViewCamera}
>
<Camera className="w-3.5 h-3.5" />
View snapshot
</button>
)}
<button
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
onClick={handleCaptureCamera}
>
<Camera className="w-3.5 h-3.5" />
{hasCamera ? "Update snapshot" : "Take snapshot"}
</button>
{hasCamera && (
<button
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
onClick={handleClearCamera}
>
<Trash2 className="w-3.5 h-3.5" />
Clear snapshot
</button>
)}
</div>
</PopoverContent>
</Popover>
</div>
);
}