feat: enhance canvas and layout components with new features and improvements
- Added remote image patterns to the Next.js configuration for enhanced image handling. - Updated TypeScript configuration to exclude the 'implement' directory. - Refactored layout component to fetch initial authentication token and pass it to Providers. - Replaced CanvasToolbar with CanvasSidebar for improved UI layout and functionality. - Enhanced Canvas component with new drag-and-drop file upload capabilities and batch node movement. - Updated various node components to support new status handling and improved user interactions. - Added debounced saving for note and prompt nodes to optimize performance.
This commit is contained in:
@@ -1,54 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
type Connection,
|
||||
type Edge as RFEdge,
|
||||
type EdgeChange,
|
||||
useReactFlow,
|
||||
type Node as RFNode,
|
||||
type Edge as RFEdge,
|
||||
type NodeChange,
|
||||
type EdgeChange,
|
||||
type Connection,
|
||||
BackgroundVariant,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
|
||||
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { convexEdgeToRF, convexNodeToRF } from "@/lib/canvas-utils";
|
||||
|
||||
import { nodeTypes } from "./node-types";
|
||||
import { convexNodeToRF, convexEdgeToRF, NODE_DEFAULTS } from "@/lib/canvas-utils";
|
||||
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
||||
|
||||
interface CanvasProps {
|
||||
interface CanvasInnerProps {
|
||||
canvasId: Id<"canvases">;
|
||||
}
|
||||
|
||||
export default function Canvas({ canvasId }: CanvasProps) {
|
||||
const convexNodes = useQuery(api.nodes.list, { canvasId });
|
||||
const convexEdges = useQuery(api.edges.list, { canvasId });
|
||||
function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();
|
||||
const shouldSkipCanvasQueries = isAuthLoading || !isAuthenticated;
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === "production") return;
|
||||
if (!isAuthLoading && !isAuthenticated) {
|
||||
console.warn("[Canvas debug] mounted without Convex auth", { canvasId });
|
||||
}
|
||||
}, [canvasId, isAuthLoading, isAuthenticated]);
|
||||
|
||||
// ─── Convex Realtime Queries ───────────────────────────────────
|
||||
const convexNodes = useQuery(
|
||||
api.nodes.list,
|
||||
shouldSkipCanvasQueries ? "skip" : { canvasId },
|
||||
);
|
||||
const convexEdges = useQuery(
|
||||
api.edges.list,
|
||||
shouldSkipCanvasQueries ? "skip" : { canvasId },
|
||||
);
|
||||
|
||||
// ─── Convex Mutations (exakte Signaturen aus nodes.ts / edges.ts) ──
|
||||
const moveNode = useMutation(api.nodes.move);
|
||||
const createEdge = useMutation(api.edges.create);
|
||||
const batchMoveNodes = useMutation(api.nodes.batchMove);
|
||||
const createNode = useMutation(api.nodes.create);
|
||||
const removeNode = useMutation(api.nodes.remove);
|
||||
const createEdge = useMutation(api.edges.create);
|
||||
const removeEdge = useMutation(api.edges.remove);
|
||||
|
||||
// ─── Lokaler State (für flüssiges Dragging) ───────────────────
|
||||
const [nodes, setNodes] = useState<RFNode[]>([]);
|
||||
const [edges, setEdges] = useState<RFEdge[]>([]);
|
||||
|
||||
// Drag-Lock: während des Drags kein Convex-Override
|
||||
const isDragging = useRef(false);
|
||||
|
||||
// ─── Convex → Lokaler State Sync ──────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!convexNodes) return;
|
||||
if (!isDragging.current) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setNodes(convexNodes.map(convexNodeToRF));
|
||||
}
|
||||
if (!convexNodes || isDragging.current) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setNodes(convexNodes.map(convexNodeToRF));
|
||||
}, [convexNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -57,75 +83,136 @@ export default function Canvas({ canvasId }: CanvasProps) {
|
||||
setEdges(convexEdges.map(convexEdgeToRF));
|
||||
}, [convexEdges]);
|
||||
|
||||
// ─── Node Changes (Drag, Select, Remove) ─────────────────────
|
||||
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
||||
setNodes((current) => applyNodeChanges(changes, current));
|
||||
setNodes((nds) => applyNodeChanges(changes, nds));
|
||||
}, []);
|
||||
|
||||
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
|
||||
setEdges((current) => applyEdgeChanges(changes, current));
|
||||
setEdges((eds) => applyEdgeChanges(changes, eds));
|
||||
}, []);
|
||||
|
||||
// ─── Drag Start → Lock ────────────────────────────────────────
|
||||
const onNodeDragStart = useCallback(() => {
|
||||
isDragging.current = true;
|
||||
}, []);
|
||||
|
||||
// ─── Drag Stop → Commit zu Convex ─────────────────────────────
|
||||
const onNodeDragStop = useCallback(
|
||||
(_event: React.MouseEvent, node: RFNode) => {
|
||||
(_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => {
|
||||
isDragging.current = false;
|
||||
void moveNode({
|
||||
nodeId: node.id as Id<"nodes">,
|
||||
positionX: node.position.x,
|
||||
positionY: node.position.y,
|
||||
});
|
||||
|
||||
// Wenn mehrere Nodes gleichzeitig gedraggt wurden → batchMove
|
||||
if (draggedNodes.length > 1) {
|
||||
batchMoveNodes({
|
||||
moves: draggedNodes.map((n) => ({
|
||||
nodeId: n.id as Id<"nodes">,
|
||||
positionX: n.position.x,
|
||||
positionY: n.position.y,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
moveNode({
|
||||
nodeId: node.id as Id<"nodes">,
|
||||
positionX: node.position.x,
|
||||
positionY: node.position.y,
|
||||
});
|
||||
}
|
||||
},
|
||||
[moveNode],
|
||||
[moveNode, batchMoveNodes],
|
||||
);
|
||||
|
||||
// ─── Neue Verbindung → Convex Edge ────────────────────────────
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (!connection.source || !connection.target) return;
|
||||
void createEdge({
|
||||
canvasId,
|
||||
sourceNodeId: connection.source as Id<"nodes">,
|
||||
targetNodeId: connection.target as Id<"nodes">,
|
||||
sourceHandle: connection.sourceHandle ?? undefined,
|
||||
targetHandle: connection.targetHandle ?? undefined,
|
||||
});
|
||||
if (connection.source && connection.target) {
|
||||
createEdge({
|
||||
canvasId,
|
||||
sourceNodeId: connection.source as Id<"nodes">,
|
||||
targetNodeId: connection.target as Id<"nodes">,
|
||||
sourceHandle: connection.sourceHandle ?? undefined,
|
||||
targetHandle: connection.targetHandle ?? undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[canvasId, createEdge],
|
||||
[createEdge, canvasId],
|
||||
);
|
||||
|
||||
// ─── Node löschen → Convex ────────────────────────────────────
|
||||
const onNodesDelete = useCallback(
|
||||
(deletedNodes: RFNode[]) => {
|
||||
for (const node of deletedNodes) {
|
||||
void removeNode({ nodeId: node.id as Id<"nodes"> });
|
||||
removeNode({ nodeId: node.id as Id<"nodes"> });
|
||||
}
|
||||
},
|
||||
[removeNode],
|
||||
);
|
||||
|
||||
// ─── Edge löschen → Convex ────────────────────────────────────
|
||||
const onEdgesDelete = useCallback(
|
||||
(deletedEdges: RFEdge[]) => {
|
||||
for (const edge of deletedEdges) {
|
||||
void removeEdge({ edgeId: edge.id as Id<"edges"> });
|
||||
removeEdge({ edgeId: edge.id as Id<"edges"> });
|
||||
}
|
||||
},
|
||||
[removeEdge],
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const nodeType = event.dataTransfer.getData(
|
||||
"application/lemonspace-node-type",
|
||||
);
|
||||
if (!nodeType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const defaults = NODE_DEFAULTS[nodeType] ?? {
|
||||
width: 200,
|
||||
height: 100,
|
||||
data: {},
|
||||
};
|
||||
|
||||
createNode({
|
||||
canvasId,
|
||||
type: nodeType,
|
||||
positionX: position.x,
|
||||
positionY: position.y,
|
||||
width: defaults.width,
|
||||
height: defaults.height,
|
||||
data: defaults.data,
|
||||
});
|
||||
},
|
||||
[screenToFlowPosition, createNode, canvasId],
|
||||
);
|
||||
|
||||
// ─── Loading State ────────────────────────────────────────────
|
||||
if (convexNodes === undefined || convexEdges === undefined) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="text-sm text-muted-foreground">Canvas laedt...</span>
|
||||
<span className="text-sm text-muted-foreground">Canvas lädt…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="relative h-full w-full">
|
||||
<CanvasToolbar canvasId={canvasId} />
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
@@ -137,18 +224,21 @@ export default function Canvas({ canvasId }: CanvasProps) {
|
||||
onConnect={onConnect}
|
||||
onNodesDelete={onNodesDelete}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
fitView
|
||||
snapToGrid
|
||||
snapGrid={[16, 16]}
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
multiSelectionKeyCode="Shift"
|
||||
proOptions={{ hideAttribution: true }}
|
||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
className="bg-background"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls className="rounded-lg! border! bg-card! shadow-sm!" />
|
||||
<Controls className="bg-card! border! shadow-sm! rounded-lg!" />
|
||||
<MiniMap
|
||||
className="rounded-lg! border! bg-card! shadow-sm!"
|
||||
className="bg-card! border! shadow-sm! rounded-lg!"
|
||||
nodeColor="#6366f1"
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
/>
|
||||
@@ -156,3 +246,15 @@ export default function Canvas({ canvasId }: CanvasProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CanvasProps {
|
||||
canvasId: Id<"canvases">;
|
||||
}
|
||||
|
||||
export default function Canvas({ canvasId }: CanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<CanvasInner canvasId={canvasId} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user