Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 0 additions & 83 deletions components/editor/canvas-node.tsx

This file was deleted.

203 changes: 33 additions & 170 deletions components/editor/canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,8 @@

import {
Component,
useCallback,
useEffect,
useMemo,
useState,
type DragEvent,
type ErrorInfo,
type ReactNode,
} from "react";
Expand All @@ -16,12 +13,6 @@ import {
ConnectionMode,
MiniMap,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type NodeChange,
type EdgeChange,
type OnConnect,
type OnDelete,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";

Expand All @@ -36,12 +27,7 @@ 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;
Expand Down Expand Up @@ -117,25 +103,9 @@ 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
Expand All @@ -149,145 +119,38 @@ function CollaborativeFlow() {
});

return (
<ReactFlowProvider>
<FlowCanvas
nodes={nodes}
edges={edges}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onNodesChange={onNodesChange as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onEdgesChange={onEdgesChange as any}
onConnect={onConnect}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onDelete={onDelete as any}
/>
</ReactFlowProvider>
);
}

/**
* 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<CanvasNode>[]) => void;
onEdgesChange: (changes: EdgeChange<CanvasEdge>[]) => 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<CanvasNode> = {
type: "add",
item: newNode,
};
onNodesChange([addChange]);
} catch (error) {
console.error("Failed to parse shape payload:", error);
}
},
[screenToFlowPosition, onNodesChange],
);

return (
<div
className="absolute inset-0"
onDragOver={handleDragOver}
onDrop={handleDrop}
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onDelete={onDelete}
fitView
connectionMode={ConnectionMode.Loose}
proOptions={{ hideAttribution: true }}
className="bg-bg-base"
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onDelete={onDelete}
nodeTypes={nodeTypes}
fitView
connectionMode={ConnectionMode.Loose}
proOptions={{ hideAttribution: true }}
className="!bg-transparent"
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="var(--border-subtle)"
/>
<MiniMap
pannable
zoomable
className="!bg-bg-surface/80 !rounded-lg"
nodeColor="var(--text-muted)"
maskColor="rgba(8, 8, 9, 0.6)"
/>
</ReactFlow>
<ShapePanel />
</div>
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color="var(--border-subtle)"
/>
<MiniMap
pannable
zoomable
className="!bg-bg-surface !border !border-border-default !rounded-xl"
nodeColor="var(--text-muted)"
maskColor="rgba(8, 8, 9, 0.7)"
/>
</ReactFlow>
);
}

/**
* Loading state shown inside the Liveblocks Suspense boundary.
* Uses transparent background to blend with the infinite canvas.
* Loading state shown inside the Liveblocks Suspense boundary. Matches
* the editor's dark surface palette so it doesn't flash a light fallback.
*/
function CanvasLoading() {
const [dots, setDots] = useState(1);
Expand All @@ -300,9 +163,9 @@ function CanvasLoading() {
}, []);

return (
<div className="flex h-full items-center justify-center bg-transparent">
<div className="flex h-full items-center justify-center bg-bg-base">
<div className="flex flex-col items-center gap-3">
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-bg-surface/50">
<div className="h-2 w-32 overflow-hidden rounded-full bg-bg-elevated">
<div className="h-full w-1/2 animate-pulse rounded-full bg-accent-primary" />
</div>
<p className="text-xs text-text-muted">
Expand All @@ -321,13 +184,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. Uses transparent background for infinite canvas look.
* page with a clear next step.
*/
function CanvasError({ message, onRetry }: CanvasErrorProps) {
return (
<div className="flex h-full items-center justify-center bg-transparent">
<div className="flex h-full items-center justify-center bg-bg-base">
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="rounded-2xl border border-state-error/30 bg-bg-surface/80 p-6 backdrop-blur-sm">
<div className="rounded-2xl border border-state-error/30 bg-bg-surface p-6">
<h3 className="text-sm font-semibold text-text-primary">
Canvas unavailable
</h3>
Expand All @@ -336,7 +199,7 @@ function CanvasError({ message, onRetry }: CanvasErrorProps) {
<button
type="button"
onClick={onRetry}
className="rounded-xl bg-bg-surface/80 px-4 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-bg-elevated backdrop-blur-sm"
className="rounded-xl bg-bg-elevated px-4 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-bg-subtle"
>
Try again
</button>
Expand Down
Loading