405 lines
10 KiB
TypeScript
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;
|
|
}
|