Files
lemonspace_app/components/canvas/canvas-placement-context.tsx

405 lines
10 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
type ReactNode,
} from "react";
import { useStore, type Edge as RFEdge } from "@xyflow/react";
import type { Id } from "@/convex/_generated/dataModel";
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
import { isOptimisticEdgeId } from "./canvas-helpers";
type CreateNodeArgs = {
canvasId: Id<"canvases">;
type: string;
positionX: number;
positionY: number;
width: number;
height: number;
data: unknown;
parentId?: Id<"nodes">;
zIndex?: number;
clientRequestId?: string;
};
type CreateNodeWithEdgeSplitArgs = {
canvasId: Id<"canvases">;
type: string;
positionX: number;
positionY: number;
width: number;
height: number;
data: unknown;
parentId?: Id<"nodes">;
zIndex?: number;
splitEdgeId: Id<"edges">;
newNodeTargetHandle?: string;
newNodeSourceHandle?: string;
splitSourceHandle?: string;
splitTargetHandle?: string;
clientRequestId?: string;
};
type CreateNodeWithEdgeFromSourceArgs = CreateNodeArgs & {
sourceNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
};
type CreateNodeWithEdgeToTargetArgs = CreateNodeArgs & {
targetNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
};
type CreateNodeMutation = (args: CreateNodeArgs) => Promise<Id<"nodes">>;
type CreateNodeWithEdgeSplitMutation = (
args: CreateNodeWithEdgeSplitArgs,
) => Promise<Id<"nodes">>;
type CreateNodeWithEdgeFromSourceMutation = (
args: CreateNodeWithEdgeFromSourceArgs,
) => Promise<Id<"nodes">>;
type CreateNodeWithEdgeToTargetMutation = (
args: CreateNodeWithEdgeToTargetArgs,
) => Promise<Id<"nodes">>;
type FlowPoint = { x: number; y: number };
type CreateNodeWithIntersectionInput = {
type: string;
position: FlowPoint;
width?: number;
height?: number;
data?: Record<string, unknown>;
/**
* Optionaler Bildschirmpunkt für Hit-Test auf eine Kante. Nur wenn gesetzt,
* kann eine bestehende Kante gesplittet werden — ohne dieses Feld niemals.
*/
clientPosition?: FlowPoint;
zIndex?: number;
/** Correlate optimistic node id with server id after create (see canvas move flush). */
clientRequestId?: string;
};
export type CreateNodeConnectedFromSourceInput = CreateNodeWithIntersectionInput & {
sourceNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
};
export type CreateNodeConnectedToTargetInput = CreateNodeWithIntersectionInput & {
targetNodeId: Id<"nodes">;
sourceHandle?: string;
targetHandle?: string;
};
type CanvasPlacementContextValue = {
createNodeWithIntersection: (
input: CreateNodeWithIntersectionInput,
) => Promise<Id<"nodes">>;
createNodeConnectedFromSource: (
input: CreateNodeConnectedFromSourceInput,
) => Promise<Id<"nodes">>;
createNodeConnectedToTarget: (
input: CreateNodeConnectedToTargetInput,
) => 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" || isOptimisticEdgeId(edge.id)) {
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">;
createNode: CreateNodeMutation;
createNodeWithEdgeSplit: CreateNodeWithEdgeSplitMutation;
createNodeWithEdgeFromSource: CreateNodeWithEdgeFromSourceMutation;
createNodeWithEdgeToTarget: CreateNodeWithEdgeToTargetMutation;
onCreateNodeSettled?: (payload: {
clientRequestId?: string;
realId: Id<"nodes">;
}) => void;
children: ReactNode;
}
export function CanvasPlacementProvider({
canvasId,
createNode,
createNodeWithEdgeSplit,
createNodeWithEdgeFromSource,
createNodeWithEdgeToTarget,
onCreateNodeSettled,
children,
}: CanvasPlacementProviderProps) {
const edges = useStore((store) => store.edges);
const createNodeWithIntersection = useCallback(
async ({
type,
position,
width,
height,
data,
clientPosition,
zIndex,
clientRequestId,
}: CreateNodeWithIntersectionInput) => {
const defaults = NODE_DEFAULTS[type] ?? {
width: 200,
height: 100,
data: {},
};
const effectiveWidth = width ?? defaults.width;
const effectiveHeight = height ?? defaults.height;
const hitEdge = clientPosition
? getIntersectedPersistedEdge(clientPosition, edges)
: undefined;
const baseNodePayload = {
canvasId,
type,
positionX: position.x,
positionY: position.y,
width: effectiveWidth,
height: effectiveHeight,
data: {
...defaults.data,
...(data ?? {}),
canvasId,
},
...(zIndex !== undefined ? { zIndex } : {}),
};
const createNodePayload = {
...baseNodePayload,
...(clientRequestId !== undefined ? { clientRequestId } : {}),
};
const notifySettled = (realId: Id<"nodes">) => {
onCreateNodeSettled?.({ clientRequestId, realId });
};
if (!hitEdge) {
const realId = await createNode(createNodePayload);
notifySettled(realId);
return realId;
}
const handles = NODE_HANDLE_MAP[type];
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
const realId = await createNode(createNodePayload);
notifySettled(realId);
return realId;
}
try {
const realId = await createNodeWithEdgeSplit({
...baseNodePayload,
splitEdgeId: hitEdge.id as Id<"edges">,
newNodeTargetHandle: normalizeHandle(handles.target),
newNodeSourceHandle: normalizeHandle(handles.source),
splitSourceHandle: normalizeHandle(hitEdge.sourceHandle),
splitTargetHandle: normalizeHandle(hitEdge.targetHandle),
...(clientRequestId !== undefined ? { clientRequestId } : {}),
});
notifySettled(realId);
return realId;
} catch (error) {
console.error("[Canvas placement] edge split failed", {
edgeId: hitEdge.id,
type,
error: String(error),
});
throw error;
}
},
[
canvasId,
createNode,
createNodeWithEdgeSplit,
edges,
onCreateNodeSettled,
],
);
const createNodeConnectedFromSource = useCallback(
async ({
type,
position,
width,
height,
data,
zIndex,
clientRequestId,
sourceNodeId,
sourceHandle,
targetHandle,
}: CreateNodeConnectedFromSourceInput) => {
const defaults = NODE_DEFAULTS[type] ?? {
width: 200,
height: 100,
data: {},
};
const effectiveWidth = width ?? defaults.width;
const effectiveHeight = height ?? defaults.height;
const payload = {
canvasId,
type,
positionX: position.x,
positionY: position.y,
width: effectiveWidth,
height: effectiveHeight,
data: {
...defaults.data,
...(data ?? {}),
canvasId,
},
...(zIndex !== undefined ? { zIndex } : {}),
...(clientRequestId !== undefined ? { clientRequestId } : {}),
sourceNodeId,
sourceHandle,
targetHandle,
};
const realId = await createNodeWithEdgeFromSource(payload);
onCreateNodeSettled?.({ clientRequestId, realId });
return realId;
},
[canvasId, createNodeWithEdgeFromSource, onCreateNodeSettled],
);
const createNodeConnectedToTarget = useCallback(
async ({
type,
position,
width,
height,
data,
zIndex,
clientRequestId,
targetNodeId,
sourceHandle,
targetHandle,
}: CreateNodeConnectedToTargetInput) => {
const defaults = NODE_DEFAULTS[type] ?? {
width: 200,
height: 100,
data: {},
};
const effectiveWidth = width ?? defaults.width;
const effectiveHeight = height ?? defaults.height;
const payload = {
canvasId,
type,
positionX: position.x,
positionY: position.y,
width: effectiveWidth,
height: effectiveHeight,
data: {
...defaults.data,
...(data ?? {}),
canvasId,
},
...(zIndex !== undefined ? { zIndex } : {}),
...(clientRequestId !== undefined ? { clientRequestId } : {}),
targetNodeId,
sourceHandle,
targetHandle,
};
const realId = await createNodeWithEdgeToTarget(payload);
onCreateNodeSettled?.({ clientRequestId, realId });
return realId;
},
[canvasId, createNodeWithEdgeToTarget, onCreateNodeSettled],
);
const value = useMemo(
() => ({
createNodeWithIntersection,
createNodeConnectedFromSource,
createNodeConnectedToTarget,
}),
[
createNodeConnectedFromSource,
createNodeConnectedToTarget,
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;
}