Files
lemonspace_app/components/canvas/canvas-placement-context.tsx
Matthias 8daa4a91fb feat: enhance canvas and node components with improved edge handling and new node features
- Refactored canvas toolbar to utilize new canvas placement context for node creation.
- Updated node components (compare, group, image, note, prompt, text) to include source and target handles for better edge management.
- Improved edge intersection handling during node drag operations for enhanced user experience.
- Added utility functions for edge identification and node positioning to streamline interactions.
2026-03-26 18:22:57 +01:00

205 lines
5.3 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
type ReactNode,
} from "react";
import { useMutation } from "convex/react";
import { useReactFlow, useStore, type Edge as RFEdge } from "@xyflow/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
type FlowPoint = { x: number; y: number };
type CreateNodeWithIntersectionInput = {
type: string;
position: FlowPoint;
width?: number;
height?: number;
data?: Record<string, unknown>;
clientPosition?: FlowPoint;
};
type CanvasPlacementContextValue = {
createNodeWithIntersection: (
input: CreateNodeWithIntersectionInput,
) => Promise<Id<"nodes">>;
};
const CanvasPlacementContext = createContext<CanvasPlacementContextValue | null>(
null,
);
function getEdgeIdFromInteractionElement(element: Element): string | null {
const edgeContainer = element.closest(".react-flow__edge");
if (!edgeContainer) return null;
const dataId = edgeContainer.getAttribute("data-id");
if (dataId) return dataId;
const domId = edgeContainer.getAttribute("id");
if (domId?.startsWith("reactflow__edge-")) {
return domId.slice("reactflow__edge-".length);
}
return null;
}
function getIntersectedPersistedEdge(
point: FlowPoint,
edges: RFEdge[],
): RFEdge | undefined {
const elements = document.elementsFromPoint(point.x, point.y);
const interactionElement = elements.find(
(element) => element.classList.contains("react-flow__edge-interaction"),
);
if (!interactionElement) {
return undefined;
}
const edgeId = getEdgeIdFromInteractionElement(interactionElement);
if (!edgeId) return undefined;
const edge = edges.find((candidate) => candidate.id === edgeId);
if (!edge || edge.className === "temp") return undefined;
return edge;
}
function hasHandleKey(
handles: { source?: string; target?: string } | undefined,
key: "source" | "target",
): boolean {
if (!handles) return false;
return Object.prototype.hasOwnProperty.call(handles, key);
}
function normalizeHandle(handle: string | null | undefined): string | undefined {
return handle ?? undefined;
}
interface CanvasPlacementProviderProps {
canvasId: Id<"canvases">;
children: ReactNode;
}
export function CanvasPlacementProvider({
canvasId,
children,
}: CanvasPlacementProviderProps) {
const { flowToScreenPosition } = useReactFlow();
const edges = useStore((store) => store.edges);
const createNode = useMutation(api.nodes.create);
const createEdge = useMutation(api.edges.create);
const removeEdge = useMutation(api.edges.remove);
const createNodeWithIntersection = useCallback(
async ({
type,
position,
width,
height,
data,
clientPosition,
}: CreateNodeWithIntersectionInput) => {
const defaults = NODE_DEFAULTS[type] ?? {
width: 200,
height: 100,
data: {},
};
const effectiveWidth = width ?? defaults.width;
const effectiveHeight = height ?? defaults.height;
const centerClientPosition = flowToScreenPosition({
x: position.x + effectiveWidth / 2,
y: position.y + effectiveHeight / 2,
});
const hitEdgeFromClientPosition = clientPosition
? getIntersectedPersistedEdge(clientPosition, edges)
: undefined;
const hitEdge =
hitEdgeFromClientPosition ??
getIntersectedPersistedEdge(centerClientPosition, edges);
const nodeId = await createNode({
canvasId,
type,
positionX: position.x,
positionY: position.y,
width: effectiveWidth,
height: effectiveHeight,
data: {
...defaults.data,
...(data ?? {}),
canvasId,
},
});
if (!hitEdge) {
return nodeId;
}
const handles = NODE_HANDLE_MAP[type];
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
return nodeId;
}
try {
await createEdge({
canvasId,
sourceNodeId: hitEdge.source as Id<"nodes">,
targetNodeId: nodeId,
sourceHandle: normalizeHandle(hitEdge.sourceHandle),
targetHandle: normalizeHandle(handles.target),
});
await createEdge({
canvasId,
sourceNodeId: nodeId,
targetNodeId: hitEdge.target as Id<"nodes">,
sourceHandle: normalizeHandle(handles.source),
targetHandle: normalizeHandle(hitEdge.targetHandle),
});
await removeEdge({ edgeId: hitEdge.id as Id<"edges"> });
} catch (error) {
console.error("[Canvas placement] edge split failed", {
edgeId: hitEdge.id,
nodeId,
type,
error: String(error),
});
}
return nodeId;
},
[canvasId, createEdge, createNode, edges, flowToScreenPosition, removeEdge],
);
const value = useMemo(
() => ({ createNodeWithIntersection }),
[createNodeWithIntersection],
);
return (
<CanvasPlacementContext.Provider value={value}>
{children}
</CanvasPlacementContext.Provider>
);
}
export function useCanvasPlacement() {
const context = useContext(CanvasPlacementContext);
if (!context) {
throw new Error("useCanvasPlacement must be used within CanvasPlacementProvider");
}
return context;
}