diff --git a/components/editor/canvas-node.tsx b/components/editor/canvas-node.tsx new file mode 100644 index 0000000..51d3b5e --- /dev/null +++ b/components/editor/canvas-node.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { memo, useMemo } from "react"; +import { Handle, Position, type NodeProps } from "@xyflow/react"; + +import { NODE_COLORS, type NodeColorName, type NodeShape } from "@/types/canvas"; + +/** + * Data attached to canvas nodes. + */ +interface CanvasNodeData { + label: string; + color: NodeColorName; + shape: NodeShape; +} + +/** + * Basic canvas node renderer. For this unit, renders every shape as a + * simple bordered rectangle with the label centered. Shape-specific + * visuals will be added later. + */ +function CanvasNode({ data, selected }: NodeProps) { + const { label, color, shape } = data as unknown as CanvasNodeData; + + // Get color from palette + const colorPair = useMemo(() => { + return NODE_COLORS.find((c) => c.name === color) ?? NODE_COLORS[0]; + }, [color]); + + // Basic shape styles - for now, all shapes render as bordered rectangles + // Shape-specific rendering will be added in future features + const shapeStyles = useMemo(() => { + const baseStyles = { + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: shape === "circle" ? "9999px" : shape === "pill" ? "9999px" : "8px", + backgroundColor: colorPair.fill, + border: `2px ${selected ? "var(--accent-primary)" : colorPair.fill}`, + boxShadow: selected + ? `0 0 0 2px var(--accent-primary), 0 4px 12px rgba(0,0,0,0.3)` + : "none", + }; + return baseStyles; + }, [colorPair.fill, selected, shape]); + + return ( +
+ + + {label || "Node"} + + +
+ ); +} + +/** + * Memoized custom node component for React Flow. + */ +export const CanvasNodeComponent = memo(CanvasNode, (prev, next) => { + return ( + prev.data.label === next.data.label && + prev.data.color === next.data.color && + prev.data.shape === next.data.shape && + prev.selected === next.selected + ); +}); + +CanvasNodeComponent.displayName = "CanvasNode"; \ No newline at end of file diff --git a/components/editor/canvas.tsx b/components/editor/canvas.tsx index 10c72ae..11d9c5d 100644 --- a/components/editor/canvas.tsx +++ b/components/editor/canvas.tsx @@ -2,8 +2,11 @@ import { Component, + useCallback, useEffect, + useMemo, useState, + type DragEvent, type ErrorInfo, type ReactNode, } from "react"; @@ -13,6 +16,12 @@ import { ConnectionMode, MiniMap, ReactFlow, + ReactFlowProvider, + useReactFlow, + type NodeChange, + type EdgeChange, + type OnConnect, + type OnDelete, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; @@ -27,7 +36,12 @@ import "@liveblocks/react-flow/styles.css"; import type { CanvasEdge, CanvasNode, + CanvasNodeData, + NodeShape, } from "@/types/canvas"; +import { NODE_COLORS, type NodeColorName } from "@/types/canvas"; +import { ShapePanel, type ShapePanelPayload } from "./shape-panel"; +import { CanvasNodeComponent } from "./canvas-node"; interface CanvasProps { roomId: string; @@ -103,9 +117,25 @@ class CanvasErrorBoundary extends Component< } } +/** + * Counter for generating unique node IDs. + */ +let nodeIdCounter = 0; + +/** + * Generate a unique node ID using shape name, timestamp, and counter. + */ +function generateNodeId(shape: NodeShape): string { + const timestamp = Date.now(); + return `${shape}-${timestamp}-${++nodeIdCounter}`; +} + /** * The actual React Flow surface, rendered after Liveblocks has connected * and the storage layer is ready (via Suspense). + * + * Note: This component is wrapped with ReactFlowProvider to allow using + * useReactFlow() hook for coordinate conversion. */ function CollaborativeFlow() { // `useLiveblocksFlow` returns the synced nodes/edges plus change @@ -119,38 +149,145 @@ function CollaborativeFlow() { }); return ( - - - + - + + ); +} + +/** + * Inner component that has access to ReactFlow context via ReactFlowProvider. + * This is where we can use useReactFlow() hook. + */ +function FlowCanvas({ + nodes, + edges, + onNodesChange, + onEdgesChange, + onConnect, + onDelete, +}: { + nodes: CanvasNode[]; + edges: CanvasEdge[]; + onNodesChange: (changes: NodeChange[]) => void; + onEdgesChange: (changes: EdgeChange[]) => void; + onConnect: OnConnect; + onDelete: (params: { nodes: CanvasNode[]; edges: CanvasEdge[] }) => void; +}) { + // Get the React Flow instance for coordinate conversion + const { screenToFlowPosition } = useReactFlow(); + + // Custom node types - memoized to keep references stable + const nodeTypes = useMemo( + () => ({ canvasNode: CanvasNodeComponent }), + [], + ); + + // Handle drag over on the canvas wrapper + const handleDragOver = useCallback((event: DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }, []); + + // Handle drop on the canvas wrapper + const handleDrop = useCallback( + (event: DragEvent) => { + event.preventDefault(); + + // Read the shape payload from dataTransfer + const payloadData = event.dataTransfer.getData("application/json"); + if (!payloadData) return; + + try { + const payload: ShapePanelPayload = JSON.parse(payloadData); + const { shape, width, height } = payload; + + // Convert screen position to canvas coordinates + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + // Create new node - use CanvasNode type from types/canvas + const newNode: CanvasNode = { + id: generateNodeId(shape), + type: "canvasNode", + position, + data: { + label: "", + color: NODE_COLORS[0].name as NodeColorName, + shape, + }, + measured: { + width, + height, + }, + }; + + // Add the node via onNodesChange with add change + const addChange: NodeChange = { + type: "add", + item: newNode, + }; + onNodesChange([addChange]); + } catch (error) { + console.error("Failed to parse shape payload:", error); + } + }, + [screenToFlowPosition, onNodesChange], + ); + + return ( +
+ + + + + +
); } /** - * Loading state shown inside the Liveblocks Suspense boundary. Matches - * the editor's dark surface palette so it doesn't flash a light fallback. + * Loading state shown inside the Liveblocks Suspense boundary. + * Uses transparent background to blend with the infinite canvas. */ function CanvasLoading() { const [dots, setDots] = useState(1); @@ -163,9 +300,9 @@ function CanvasLoading() { }, []); return ( -
+
-
+

@@ -184,13 +321,13 @@ interface CanvasErrorProps { /** * Fallback rendered when the Liveblocks connection cannot be established * (auth failure, room ID changed, full room, etc.). Keeps the user on the - * page with a clear next step. + * page with a clear next step. Uses transparent background for infinite canvas look. */ function CanvasError({ message, onRetry }: CanvasErrorProps) { return ( -

+
-
+

Canvas unavailable

@@ -199,7 +336,7 @@ function CanvasError({ message, onRetry }: CanvasErrorProps) { diff --git a/components/editor/shape-panel.tsx b/components/editor/shape-panel.tsx new file mode 100644 index 0000000..d3b0867 --- /dev/null +++ b/components/editor/shape-panel.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { + Square, + Diamond, + Circle, + Pill, + Cylinder, + Hexagon, + type LucideIcon, +} from "lucide-react"; + +import type { NodeShape } from "@/types/canvas"; + +/** + * Shape definitions with their icons and default sizes. + * Default sizes follow the spec: rectangles wider than tall, circles square, + * diamonds slightly larger for labels. + */ +const SHAPES: Array<{ + name: NodeShape; + icon: LucideIcon; + width: number; + height: number; +}> = [ + { name: "rectangle", icon: Square, width: 180, height: 100 }, + { name: "diamond", icon: Diamond, width: 140, height: 140 }, + { name: "circle", icon: Circle, width: 120, height: 120 }, + { name: "pill", icon: Pill, width: 180, height: 80 }, + { name: "cylinder", icon: Cylinder, width: 100, height: 120 }, + { name: "hexagon", icon: Hexagon, width: 140, height: 120 }, +]; + +/** + * Shape panel payload transferred via drag-and-drop. + */ +export interface ShapePanelPayload { + shape: NodeShape; + width: number; + height: number; +} + +/** + * Floating pill-shaped toolbar at the bottom-center of the canvas. + * Contains draggable icon buttons for each supported shape. + */ +export function ShapePanel() { + return ( +
+
+ {SHAPES.map((shape) => ( + + ))} +
+
+ ); +} + +interface ShapeButtonProps { + name: NodeShape; + Icon: LucideIcon; + width: number; + height: number; +} + +function ShapeButton({ name, Icon, width, height }: ShapeButtonProps) { + const [isDragging, setIsDragging] = useState(false); + + const handleDragStart = useCallback( + (e: React.DragEvent) => { + const payload: ShapePanelPayload = { shape: name, width, height }; + e.dataTransfer.setData("application/json", JSON.stringify(payload)); + e.dataTransfer.effectAllowed = "copy"; + setIsDragging(true); + }, + [name, width, height], + ); + + const handleDragEnd = useCallback(() => { + setIsDragging(false); + }, []); + + return ( + + ); +} \ No newline at end of file diff --git a/components/editor/workspace-shell.tsx b/components/editor/workspace-shell.tsx index f7eafa9..19bab94 100644 --- a/components/editor/workspace-shell.tsx +++ b/components/editor/workspace-shell.tsx @@ -57,20 +57,21 @@ export function WorkspaceShell({ aiSidebarOpen={isAiSidebarOpen} onShareClick={() => {}} /> -
- setIsSidebarOpen(false)} - ownedProjects={ownedProjects} - sharedProjects={sharedProjects} - currentRoomId={currentRoomId} - /> + {/* Canvas area with access denied message */} +
- setIsAiSidebarOpen(false)} - /> -
+
+ setIsSidebarOpen(false)} + ownedProjects={ownedProjects} + sharedProjects={sharedProjects} + currentRoomId={currentRoomId} + /> + setIsAiSidebarOpen(false)} + />
); } @@ -85,20 +86,22 @@ export function WorkspaceShell({ aiSidebarOpen={isAiSidebarOpen} onShareClick={shareDialog.onOpenChange} /> -
- setIsSidebarOpen(false)} - ownedProjects={ownedProjects} - sharedProjects={sharedProjects} - currentRoomId={currentRoomId} - /> + {/* Canvas fills full viewport - sidebars float over it */} +
- setIsAiSidebarOpen(false)} - /> -
+
+ {/* Sidebars rendered outside canvas container so they float over it */} + setIsSidebarOpen(false)} + ownedProjects={ownedProjects} + sharedProjects={sharedProjects} + currentRoomId={currentRoomId} + /> + setIsAiSidebarOpen(false)} + /> {/* Share Dialog */}
@@ -386,7 +389,7 @@ function AiSidebar({ isOpen, onClose }: AiSidebarProps) { onClick={onClose} aria-label="Close AI chat" className={cn( - "fixed inset-0 z-30 bg-bg-base/60 backdrop-blur-sm transition-opacity duration-300 ease-in-out md:hidden", + "fixed inset-0 z-40 bg-bg-base/60 backdrop-blur-sm transition-opacity duration-300 ease-in-out md:hidden", isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0", @@ -395,7 +398,7 @@ function AiSidebar({ isOpen, onClose }: AiSidebarProps) {