rename items

This commit is contained in:
wass08
2026-01-29 15:37:51 +09:00
parent 9c185c4d33
commit 07dee4aefc
14 changed files with 543 additions and 240 deletions
-27
View File
@@ -29,9 +29,6 @@ export default function Editor() {
return (
<div className="w-full h-full">
{/* <LevelModeSwitcher /> */}
<TestUndo />
<ActionMenu />
<ReferencePanel />
<RoofPanel />
@@ -52,30 +49,6 @@ export default function Editor() {
)
}
const TestUndo = () => {
const { undo, redo, futureStates, pastStates } = useScene.temporal.getState()
return (
<div className="absolute top-4 right-4 z-10 flex gap-2">
<button
className="px-4 py-2 rounded bg-white"
onClick={() => {
undo()
}}
>
Undo
</button>
<button
className="px-4 py-2 rounded bg-white"
onClick={() => {
redo()
}}
>
Redo
</button>
</div>
)
}
const Grid = ({
cellSize = 0.5,
@@ -2,7 +2,8 @@
import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Home, X } from 'lucide-react'
import { X } from 'lucide-react'
import Image from 'next/image'
import { useCallback } from 'react'
export function RoofPanel() {
@@ -40,7 +41,7 @@ export function RoofPanel() {
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex items-center gap-2 min-w-0">
<Home className="h-4 w-4 shrink-0 text-muted-foreground" />
<Image src="/icons/roof.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold text-foreground text-sm truncate">
{node.name || 'Gable Roof'}
</h2>
@@ -1,7 +1,8 @@
import { CeilingNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { Square } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { RenamePopover } from "./rename-popover";
import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
@@ -12,6 +13,7 @@ interface CeilingTreeNodeProps {
export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
const [expanded, setExpanded] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection);
@@ -21,6 +23,10 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
setSelection({ selectedIds: [node.id] });
};
const handleDoubleClick = () => {
setRenameOpen(true);
};
const handleMouseEnter = () => {
setHoveredId(node.id);
};
@@ -31,26 +37,35 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
// Calculate approximate area from polygon
const area = calculatePolygonArea(node.polygon).toFixed(1);
const defaultName = `Ceiling (${area}m²)`;
return (
<TreeNodeWrapper
icon={<Square className="w-3.5 h-3.5" />}
label={node.name || `Ceiling (${area}m²)`}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
{node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</TreeNodeWrapper>
<TreeNodeWrapper
icon={<Image src="/icons/ceiling.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || defaultName}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
>
{node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</TreeNodeWrapper>
</RenamePopover>
);
}
@@ -8,7 +8,6 @@ import { useViewer } from "@pascal-app/viewer";
import {
Building2,
ChevronDown,
Hexagon,
Layers,
MoreHorizontal,
Plus,
@@ -19,6 +18,7 @@ import { cn } from "@/lib/utils";
import useEditor from "@/store/use-editor";
import { TreeNode } from "./tree-node";
import { ReferencesDialog } from "./references-dialog";
import { RenamePopover } from "./rename-popover";
import {
Popover,
PopoverContent,
@@ -293,7 +293,20 @@ function LayerToggle() {
);
}
function calculatePolygonArea(polygon: Array<[number, number]>): number {
if (polygon.length < 3) return 0;
let area = 0;
const n = polygon.length;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
area += polygon[i]![0] * polygon[j]![1];
area -= polygon[j]![0] * polygon[i]![1];
}
return Math.abs(area) / 2;
}
function ZoneItem({ zone }: { zone: ZoneNode }) {
const [renameOpen, setRenameOpen] = useState(false);
const deleteNode = useScene((state) => state.deleteNode);
const updateNode = useScene((state) => state.updateNode);
const selectedZoneId = useViewer((state) => state.selection.zoneId);
@@ -306,12 +319,19 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
const isSelected = selectedZoneId === zone.id;
const isHovered = hoveredId === zone.id;
const area = calculatePolygonArea(zone.polygon).toFixed(1);
const defaultName = `Zone (${area}m²)`;
const handleClick = () => {
setSelection({ zoneId: zone.id });
setPhase("structure");
setMode("select");
};
const handleDoubleClick = () => {
setRenameOpen(true);
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
deleteNode(zone.id);
@@ -325,56 +345,63 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
};
return (
<div
className={cn(
"flex items-center h-7 cursor-pointer group/row text-sm px-3",
isSelected
? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
: isHovered
? "bg-accent/70 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
)}
onClick={handleClick}
onMouseEnter={() => setHoveredId(zone.id)}
onMouseLeave={() => setHoveredId(null)}
<RenamePopover
node={zone}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<Popover>
<PopoverTrigger asChild>
<button
className="mr-2 size-3 shrink-0 rounded-sm border border-border/50 transition-transform hover:scale-110 cursor-pointer"
onClick={(e) => e.stopPropagation()}
style={{ backgroundColor: zone.color }}
/>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto p-2"
onClick={(e) => e.stopPropagation()}
>
<div className="grid grid-cols-4 gap-1">
{PRESET_COLORS.map((color) => (
<button
className={cn(
"size-6 rounded-sm border transition-transform hover:scale-110 cursor-pointer",
color === zone.color ? "ring-2 ring-primary ring-offset-1" : ""
)}
key={color}
onClick={() => handleColorChange(color)}
style={{ backgroundColor: color }}
/>
))}
</div>
</PopoverContent>
</Popover>
<Hexagon className="w-3.5 h-3.5 mr-1.5 shrink-0" />
<span className="truncate flex-1">{zone.name}</span>
<button
className="opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
onClick={handleDelete}
<div
className={cn(
"flex items-center h-7 cursor-pointer group/row text-sm px-3 select-none",
isSelected
? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
: isHovered
? "bg-accent/70 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={() => setHoveredId(zone.id)}
onMouseLeave={() => setHoveredId(null)}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
<Popover>
<PopoverTrigger asChild>
<button
className="mr-2 size-3 shrink-0 rounded-sm border border-border/50 transition-transform hover:scale-110 cursor-pointer"
onClick={(e) => e.stopPropagation()}
style={{ backgroundColor: zone.color }}
/>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto p-2"
onClick={(e) => e.stopPropagation()}
>
<div className="grid grid-cols-4 gap-1">
{PRESET_COLORS.map((color) => (
<button
className={cn(
"size-6 rounded-sm border transition-transform hover:scale-110 cursor-pointer",
color === zone.color ? "ring-2 ring-primary ring-offset-1" : ""
)}
key={color}
onClick={() => handleColorChange(color)}
style={{ backgroundColor: color }}
/>
))}
</div>
</PopoverContent>
</Popover>
<span className="truncate flex-1">{zone.name || defaultName}</span>
<button
className="opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
onClick={handleDelete}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</RenamePopover>
);
}
@@ -432,8 +459,13 @@ function ContentSection() {
);
}
// Show elements (walls, items, etc.) for this level
if (level.children.length === 0) {
// Show elements (walls, items, etc.) for this level - filter out zones
const elementChildren = level.children.filter((childId) => {
const childNode = nodes[childId];
return childNode && childNode.type !== "zone";
});
if (elementChildren.length === 0) {
return (
<div className="px-3 py-4 text-sm text-muted-foreground">
No elements on this level
@@ -443,7 +475,7 @@ function ContentSection() {
return (
<div className="py-1">
{level.children.map((childId) => (
{elementChildren.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={0} />
))}
</div>
@@ -1,15 +1,29 @@
import { ItemNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { Box } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { RenamePopover } from "./rename-popover";
import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
const CATEGORY_ICONS: Record<string, string> = {
door: "/icons/door.png",
window: "/icons/window.png",
furniture: "/icons/couch.png",
appliance: "/icons/appliance.png",
kitchen: "/icons/kitchen.png",
bathroom: "/icons/bathroom.png",
outdoor: "/icons/tree.png",
};
interface ItemTreeNodeProps {
node: ItemNode;
depth: number;
}
export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
const [renameOpen, setRenameOpen] = useState(false);
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection);
@@ -19,6 +33,10 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
setSelection({ selectedIds: [node.id] });
};
const handleDoubleClick = () => {
setRenameOpen(true);
};
const handleMouseEnter = () => {
setHoveredId(node.id);
};
@@ -27,20 +45,30 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
setHoveredId(null);
};
const defaultName = node.asset.name || "Item";
return (
<TreeNodeWrapper
icon={<Box className="w-3.5 h-3.5" />}
label={node.name || node.asset.name || "Item"}
depth={depth}
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
/>
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />}
label={node.name || defaultName}
depth={depth}
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
/>
</RenamePopover>
);
}
@@ -2,6 +2,7 @@ import { LevelNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { Layers } from "lucide-react";
import { useState } from "react";
import { RenamePopover } from "./rename-popover";
import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
@@ -12,6 +13,7 @@ interface LevelTreeNodeProps {
export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
const [expanded, setExpanded] = useState(true);
const [renameOpen, setRenameOpen] = useState(false);
const isSelected = useViewer((state) => state.selection.levelId === node.id);
const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection);
@@ -20,22 +22,36 @@ export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
setSelection({ levelId: node.id });
};
const handleDoubleClick = () => {
setRenameOpen(true);
};
const defaultName = `Level ${node.level}`;
return (
<TreeNodeWrapper
icon={<Layers className="w-3.5 h-3.5" />}
label={node.name || `Level ${node.level}`}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
{node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</TreeNodeWrapper>
<TreeNodeWrapper
icon={<Layers className="w-3.5 h-3.5" />}
label={node.name || defaultName}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
>
{node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</TreeNodeWrapper>
</RenamePopover>
);
}
@@ -0,0 +1,94 @@
import { useScene, type AnyNode } from "@pascal-app/core";
import { Check, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/primitives/popover";
interface RenamePopoverProps {
node: AnyNode;
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
defaultName: string;
}
export function RenamePopover({
node,
open,
onOpenChange,
children,
defaultName,
}: RenamePopoverProps) {
const updateNode = useScene((s) => s.updateNode);
const [value, setValue] = useState(node.name || "");
const inputRef = useRef<HTMLInputElement>(null);
// Reset value when popover opens
useEffect(() => {
if (open) {
setValue(node.name || "");
// Focus and select all text after a short delay
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
}
}, [open, node.name]);
const handleSave = useCallback(() => {
const trimmed = value.trim();
// Only update if name actually changed
if (trimmed !== node.name) {
updateNode(node.id, { name: trimmed || undefined });
}
onOpenChange(false);
}, [value, node.id, node.name, updateNode, onOpenChange]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
e.preventDefault();
onOpenChange(false);
}
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverAnchor asChild>{children}</PopoverAnchor>
<PopoverContent
className="w-64 p-2 z-50"
align="start"
side="right"
sideOffset={8}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex items-center gap-1">
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={defaultName}
className="flex-1 rounded border border-input bg-background px-2 py-1 text-sm outline-none focus:border-primary"
/>
<button
type="button"
className="w-7 h-7 flex items-center justify-center rounded hover:bg-accent text-muted-foreground hover:text-foreground"
onClick={handleSave}
>
<Check className="w-4 h-4" />
</button>
<button
type="button"
className="w-7 h-7 flex items-center justify-center rounded hover:bg-accent text-muted-foreground hover:text-foreground"
onClick={() => onOpenChange(false)}
>
<X className="w-4 h-4" />
</button>
</div>
</PopoverContent>
</Popover>
);
}
@@ -1,6 +1,8 @@
import { RoofNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { Home } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { RenamePopover } from "./rename-popover";
import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
@@ -10,6 +12,7 @@ interface RoofTreeNodeProps {
}
export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
const [renameOpen, setRenameOpen] = useState(false);
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection);
@@ -19,6 +22,10 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
setSelection({ selectedIds: [node.id] });
};
const handleDoubleClick = () => {
setRenameOpen(true);
};
const handleMouseEnter = () => {
setHoveredId(node.id);
};
@@ -30,21 +37,30 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
// Calculate dimensions: length × total width (leftWidth + rightWidth)
const totalWidth = node.leftWidth + node.rightWidth;
const sizeLabel = `${node.length.toFixed(1)}×${totalWidth.toFixed(1)}m`;
const defaultName = `Roof (${sizeLabel})`;
return (
<TreeNodeWrapper
icon={<Home className="w-3.5 h-3.5" />}
label={node.name || `Roof (${sizeLabel})`}
depth={depth}
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
/>
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={<Image src="/icons/roof.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || defaultName}
depth={depth}
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
/>
</RenamePopover>
);
}
@@ -1,6 +1,8 @@
import { SlabNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { RectangleHorizontal } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { RenamePopover } from "./rename-popover";
import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
@@ -10,6 +12,7 @@ interface SlabTreeNodeProps {
}
export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
const [renameOpen, setRenameOpen] = useState(false);
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection);
@@ -19,6 +22,10 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
setSelection({ selectedIds: [node.id] });
};
const handleDoubleClick = () => {
setRenameOpen(true);
};
const handleMouseEnter = () => {
setHoveredId(node.id);
};
@@ -29,22 +36,31 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
// Calculate approximate area from polygon
const area = calculatePolygonArea(node.polygon).toFixed(1);
const defaultName = `Slab (${area}m²)`;
return (
<TreeNodeWrapper
icon={<RectangleHorizontal className="w-3.5 h-3.5" />}
label={node.name || `Slab (${area}m²)`}
depth={depth}
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
/>
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={<Image src="/icons/floor.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || defaultName}
depth={depth}
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
/>
</RenamePopover>
);
}
@@ -1,5 +1,6 @@
import { AnyNodeId, useScene } from "@pascal-app/core";
import { ChevronDown, ChevronRight } from "lucide-react";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
import { BuildingTreeNode } from "./building-tree-node";
import { CeilingTreeNode } from "./ceiling-tree-node";
@@ -8,6 +9,7 @@ import { LevelTreeNode } from "./level-tree-node";
import { RoofTreeNode } from "./roof-tree-node";
import { SlabTreeNode } from "./slab-tree-node";
import { WallTreeNode } from "./wall-tree-node";
import { ZoneTreeNode } from "./zone-tree-node";
interface TreeNodeProps {
nodeId: AnyNodeId;
@@ -34,6 +36,8 @@ export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) {
return <RoofTreeNode node={node} depth={depth} />;
case "item":
return <ItemTreeNode node={node} depth={depth} />;
case "zone":
return <ZoneTreeNode node={node} depth={depth} />;
default:
return null;
}
@@ -47,6 +51,7 @@ interface TreeNodeWrapperProps {
expanded: boolean;
onToggle: () => void;
onClick: () => void;
onDoubleClick?: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
actions?: React.ReactNode;
@@ -55,67 +60,74 @@ interface TreeNodeWrapperProps {
isHovered?: boolean;
}
export function TreeNodeWrapper({
icon,
label,
depth,
hasChildren,
expanded,
onToggle,
onClick,
onMouseEnter,
onMouseLeave,
actions,
children,
isSelected,
isHovered,
}: TreeNodeWrapperProps) {
return (
<div>
<div
className={cn(
"flex items-center h-7 cursor-pointer group/row text-sm",
isSelected
? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
: isHovered
? "bg-accent/70 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
)}
style={{ paddingLeft: depth * 12 + 4 }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<button
className="w-4 h-4 flex items-center justify-center shrink-0"
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
{hasChildren ? (
expanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)
) : null}
</button>
export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
function TreeNodeWrapper(
{
icon,
label,
depth,
hasChildren,
expanded,
onToggle,
onClick,
onDoubleClick,
onMouseEnter,
onMouseLeave,
actions,
children,
isSelected,
isHovered,
},
ref
) {
return (
<div ref={ref}>
<div
className="flex items-center gap-1.5 flex-1 min-w-0"
onClick={onClick}
className={cn(
"flex items-center h-7 cursor-pointer group/row text-sm select-none",
isSelected
? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
: isHovered
? "bg-accent/70 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
)}
style={{ paddingLeft: depth * 12 + 4 }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
{icon}
</span>
<span className="truncate">{label}</span>
</div>
{actions && (
<div className="opacity-0 group-hover/row:opacity-100 pr-1">
{actions}
<button
className="w-4 h-4 flex items-center justify-center shrink-0"
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
{hasChildren ? (
expanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)
) : null}
</button>
<div
className="flex items-center gap-1.5 flex-1 min-w-0"
onClick={onClick}
onDoubleClick={onDoubleClick}
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
{icon}
</span>
<span className="truncate">{label}</span>
</div>
)}
{actions && (
<div className="opacity-0 group-hover/row:opacity-100 pr-1">
{actions}
</div>
)}
</div>
{expanded && children}
</div>
{expanded && children}
</div>
);
}
);
}
);
@@ -1,7 +1,8 @@
import { WallNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { Square } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { RenamePopover } from "./rename-popover";
import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
@@ -12,6 +13,7 @@ interface WallTreeNodeProps {
export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
const [expanded, setExpanded] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection);
@@ -21,6 +23,10 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
setSelection({ selectedIds: [node.id] });
};
const handleDoubleClick = () => {
setRenameOpen(true);
};
const handleMouseEnter = () => {
setHoveredId(node.id);
};
@@ -34,24 +40,34 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
Math.pow(node.end[1] - node.start[1], 2),
).toFixed(1);
const defaultName = `Wall (${wallLength}m/${node.height || 2.5}m)`;
return (
<TreeNodeWrapper
icon={<Square className="w-3.5 h-3.5" />}
label={node.name || `Wall (${wallLength}m/${node.height || 2.5}m)`}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
{node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</TreeNodeWrapper>
<TreeNodeWrapper
icon={<Image src="/icons/wall.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || defaultName}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
>
{node.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</TreeNodeWrapper>
</RenamePopover>
);
}
@@ -0,0 +1,87 @@
import { ZoneNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { useState } from "react";
import { RenamePopover } from "./rename-popover";
import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
interface ZoneTreeNodeProps {
node: ZoneNode;
depth: number;
}
export function ZoneTreeNode({ node, depth }: ZoneTreeNodeProps) {
const [renameOpen, setRenameOpen] = useState(false);
const isSelected = useViewer((state) => state.selection.zoneId === node.id);
const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection);
const setHoveredId = useViewer((state) => state.setHoveredId);
const handleClick = () => {
setSelection({ zoneId: node.id });
};
const handleDoubleClick = () => {
setRenameOpen(true);
};
const handleMouseEnter = () => {
setHoveredId(node.id);
};
const handleMouseLeave = () => {
setHoveredId(null);
};
// Calculate approximate area from polygon
const area = calculatePolygonArea(node.polygon).toFixed(1);
const defaultName = `Zone (${area}m²)`;
return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={
<div
className="w-3 h-3 rounded-sm border border-border/50"
style={{ backgroundColor: node.color }}
/>
}
label={node.name || defaultName}
depth={depth}
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
/>
</RenamePopover>
);
}
/**
* Calculate the area of a polygon using the shoelace formula
*/
function calculatePolygonArea(polygon: Array<[number, number]>): number {
if (polygon.length < 3) return 0;
let area = 0;
const n = polygon.length;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
area += polygon[i]![0] * polygon[j]![1];
area -= polygon[j]![0] * polygon[i]![1];
}
return Math.abs(area) / 2;
}