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.
This commit is contained in:
204
components/canvas/canvas-placement-context.tsx
Normal file
204
components/canvas/canvas-placement-context.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"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;
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMutation } from "convex/react";
|
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api";
|
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
|
||||||
import { ExportButton } from "@/components/canvas/export-button";
|
import { ExportButton } from "@/components/canvas/export-button";
|
||||||
|
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
||||||
|
|
||||||
const nodeTemplates = [
|
const nodeTemplates = [
|
||||||
{
|
{
|
||||||
@@ -53,15 +51,13 @@ const nodeTemplates = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
interface CanvasToolbarProps {
|
interface CanvasToolbarProps {
|
||||||
canvasId: Id<"canvases">;
|
|
||||||
canvasName?: string;
|
canvasName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CanvasToolbar({
|
export default function CanvasToolbar({
|
||||||
canvasId,
|
|
||||||
canvasName,
|
canvasName,
|
||||||
}: CanvasToolbarProps) {
|
}: CanvasToolbarProps) {
|
||||||
const createNode = useMutation(api.nodes.create);
|
const { createNodeWithIntersection } = useCanvasPlacement();
|
||||||
const nodeCountRef = useRef(0);
|
const nodeCountRef = useRef(0);
|
||||||
|
|
||||||
const handleAddNode = async (
|
const handleAddNode = async (
|
||||||
@@ -72,14 +68,12 @@ export default function CanvasToolbar({
|
|||||||
) => {
|
) => {
|
||||||
const offset = (nodeCountRef.current % 8) * 24;
|
const offset = (nodeCountRef.current % 8) * 24;
|
||||||
nodeCountRef.current += 1;
|
nodeCountRef.current += 1;
|
||||||
await createNode({
|
await createNodeWithIntersection({
|
||||||
canvasId,
|
|
||||||
type,
|
type,
|
||||||
positionX: 100 + offset,
|
position: { x: 100 + offset, y: 100 + offset },
|
||||||
positionY: 100 + offset,
|
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
data: { ...data, canvasId },
|
data,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type NodeChange,
|
type NodeChange,
|
||||||
type EdgeChange,
|
type EdgeChange,
|
||||||
type Connection,
|
type Connection,
|
||||||
|
type DefaultEdgeOptions,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
@@ -27,8 +28,14 @@ import type { Id } from "@/convex/_generated/dataModel";
|
|||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
import { nodeTypes } from "./node-types";
|
import { nodeTypes } from "./node-types";
|
||||||
import { convexNodeToRF, convexEdgeToRF, NODE_DEFAULTS } from "@/lib/canvas-utils";
|
import {
|
||||||
|
convexNodeToRF,
|
||||||
|
convexEdgeToRF,
|
||||||
|
NODE_DEFAULTS,
|
||||||
|
NODE_HANDLE_MAP,
|
||||||
|
} from "@/lib/canvas-utils";
|
||||||
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
||||||
|
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
|
||||||
|
|
||||||
interface CanvasInnerProps {
|
interface CanvasInnerProps {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
@@ -97,6 +104,68 @@ function getMiniMapNodeStrokeColor(node: RFNode): string {
|
|||||||
return node.type === "frame" ? "transparent" : "#4f46e5";
|
return node.type === "frame" ? "transparent" : "#4f46e5";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_EDGE_OPTIONS: DefaultEdgeOptions = {
|
||||||
|
interactionWidth: 75,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EDGE_INTERSECTION_HIGHLIGHT_STYLE: NonNullable<RFEdge["style"]> = {
|
||||||
|
stroke: "hsl(var(--foreground))",
|
||||||
|
strokeWidth: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 getNodeCenterClientPosition(nodeId: string): { x: number; y: number } | null {
|
||||||
|
const nodeElement = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>(".react-flow__node"),
|
||||||
|
).find((element) => element.dataset.id === nodeId);
|
||||||
|
|
||||||
|
if (!nodeElement) return null;
|
||||||
|
|
||||||
|
const rect = nodeElement.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.top + rect.height / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIntersectedEdgeId(point: { x: number; y: number }): string | null {
|
||||||
|
const interactionElement = document
|
||||||
|
.elementsFromPoint(point.x, point.y)
|
||||||
|
.find((element) => element.classList.contains("react-flow__edge-interaction"));
|
||||||
|
|
||||||
|
if (!interactionElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getEdgeIdFromInteractionElement(interactionElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function CanvasInner({ canvasId }: CanvasInnerProps) {
|
function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||||
const { screenToFlowPosition } = useReactFlow();
|
const { screenToFlowPosition } = useReactFlow();
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
@@ -175,6 +244,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
// Delete Edge on Drop
|
// Delete Edge on Drop
|
||||||
const edgeReconnectSuccessful = useRef(true);
|
const edgeReconnectSuccessful = useRef(true);
|
||||||
|
const overlappedEdgeRef = useRef<string | null>(null);
|
||||||
|
const highlightedEdgeRef = useRef<string | null>(null);
|
||||||
|
const highlightedEdgeOriginalStyleRef = useRef<RFEdge["style"] | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Convex → Lokaler State Sync ──────────────────────────────
|
// ─── Convex → Lokaler State Sync ──────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -269,36 +343,195 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
[removeEdge],
|
[removeEdge],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setHighlightedIntersectionEdge = useCallback((edgeId: string | null) => {
|
||||||
|
const previousHighlightedEdgeId = highlightedEdgeRef.current;
|
||||||
|
if (previousHighlightedEdgeId === edgeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEdges((currentEdges) => {
|
||||||
|
let nextEdges = currentEdges;
|
||||||
|
|
||||||
|
if (previousHighlightedEdgeId) {
|
||||||
|
nextEdges = nextEdges.map((edge) =>
|
||||||
|
edge.id === previousHighlightedEdgeId
|
||||||
|
? {
|
||||||
|
...edge,
|
||||||
|
style: highlightedEdgeOriginalStyleRef.current,
|
||||||
|
}
|
||||||
|
: edge,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!edgeId) {
|
||||||
|
highlightedEdgeOriginalStyleRef.current = undefined;
|
||||||
|
return nextEdges;
|
||||||
|
}
|
||||||
|
|
||||||
|
const edgeToHighlight = nextEdges.find((edge) => edge.id === edgeId);
|
||||||
|
if (!edgeToHighlight || edgeToHighlight.className === "temp") {
|
||||||
|
highlightedEdgeOriginalStyleRef.current = undefined;
|
||||||
|
return nextEdges;
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightedEdgeOriginalStyleRef.current = edgeToHighlight.style;
|
||||||
|
|
||||||
|
return nextEdges.map((edge) =>
|
||||||
|
edge.id === edgeId
|
||||||
|
? {
|
||||||
|
...edge,
|
||||||
|
style: {
|
||||||
|
...(edge.style ?? {}),
|
||||||
|
...EDGE_INTERSECTION_HIGHLIGHT_STYLE,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: edge,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
highlightedEdgeRef.current = edgeId;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onNodeDrag = useCallback(
|
||||||
|
(_event: React.MouseEvent, node: RFNode) => {
|
||||||
|
const nodeCenter = getNodeCenterClientPosition(node.id);
|
||||||
|
if (!nodeCenter) {
|
||||||
|
overlappedEdgeRef.current = null;
|
||||||
|
setHighlightedIntersectionEdge(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intersectedEdgeId = getIntersectedEdgeId(nodeCenter);
|
||||||
|
if (!intersectedEdgeId) {
|
||||||
|
overlappedEdgeRef.current = null;
|
||||||
|
setHighlightedIntersectionEdge(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intersectedEdge = edges.find(
|
||||||
|
(edge) => edge.id === intersectedEdgeId && edge.className !== "temp",
|
||||||
|
);
|
||||||
|
if (!intersectedEdge) {
|
||||||
|
overlappedEdgeRef.current = null;
|
||||||
|
setHighlightedIntersectionEdge(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
intersectedEdge.source === node.id ||
|
||||||
|
intersectedEdge.target === node.id
|
||||||
|
) {
|
||||||
|
overlappedEdgeRef.current = null;
|
||||||
|
setHighlightedIntersectionEdge(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handles = NODE_HANDLE_MAP[node.type ?? ""];
|
||||||
|
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
|
||||||
|
overlappedEdgeRef.current = null;
|
||||||
|
setHighlightedIntersectionEdge(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlappedEdgeRef.current = intersectedEdge.id;
|
||||||
|
setHighlightedIntersectionEdge(intersectedEdge.id);
|
||||||
|
},
|
||||||
|
[edges, setHighlightedIntersectionEdge],
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Drag Start → Lock ────────────────────────────────────────
|
// ─── Drag Start → Lock ────────────────────────────────────────
|
||||||
const onNodeDragStart = useCallback(() => {
|
const onNodeDragStart = useCallback(() => {
|
||||||
isDragging.current = true;
|
isDragging.current = true;
|
||||||
}, []);
|
overlappedEdgeRef.current = null;
|
||||||
|
setHighlightedIntersectionEdge(null);
|
||||||
|
}, [setHighlightedIntersectionEdge]);
|
||||||
|
|
||||||
// ─── Drag Stop → Commit zu Convex ─────────────────────────────
|
// ─── Drag Stop → Commit zu Convex ─────────────────────────────
|
||||||
const onNodeDragStop = useCallback(
|
const onNodeDragStop = useCallback(
|
||||||
(_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => {
|
(_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => {
|
||||||
// isDragging bleibt true bis die Mutation resolved ist → kein Convex-Override möglich
|
const intersectedEdgeId = overlappedEdgeRef.current;
|
||||||
if (draggedNodes.length > 1) {
|
|
||||||
void batchMoveNodes({
|
void (async () => {
|
||||||
moves: draggedNodes.map((n) => ({
|
try {
|
||||||
nodeId: n.id as Id<"nodes">,
|
// isDragging bleibt true bis alle Mutations resolved sind
|
||||||
positionX: n.position.x,
|
if (draggedNodes.length > 1) {
|
||||||
positionY: n.position.y,
|
await batchMoveNodes({
|
||||||
})),
|
moves: draggedNodes.map((n) => ({
|
||||||
}).then(() => {
|
nodeId: n.id as Id<"nodes">,
|
||||||
|
positionX: n.position.x,
|
||||||
|
positionY: n.position.y,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await moveNode({
|
||||||
|
nodeId: node.id as Id<"nodes">,
|
||||||
|
positionX: node.position.x,
|
||||||
|
positionY: node.position.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!intersectedEdgeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intersectedEdge = edges.find((edge) => edge.id === intersectedEdgeId);
|
||||||
|
if (!intersectedEdge || intersectedEdge.className === "temp") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
intersectedEdge.source === node.id ||
|
||||||
|
intersectedEdge.target === node.id
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handles = NODE_HANDLE_MAP[node.type ?? ""];
|
||||||
|
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createEdge({
|
||||||
|
canvasId,
|
||||||
|
sourceNodeId: intersectedEdge.source as Id<"nodes">,
|
||||||
|
targetNodeId: node.id as Id<"nodes">,
|
||||||
|
sourceHandle: normalizeHandle(intersectedEdge.sourceHandle),
|
||||||
|
targetHandle: normalizeHandle(handles.target),
|
||||||
|
});
|
||||||
|
|
||||||
|
await createEdge({
|
||||||
|
canvasId,
|
||||||
|
sourceNodeId: node.id as Id<"nodes">,
|
||||||
|
targetNodeId: intersectedEdge.target as Id<"nodes">,
|
||||||
|
sourceHandle: normalizeHandle(handles.source),
|
||||||
|
targetHandle: normalizeHandle(intersectedEdge.targetHandle),
|
||||||
|
});
|
||||||
|
|
||||||
|
await removeEdge({ edgeId: intersectedEdge.id as Id<"edges"> });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Canvas edge intersection split failed]", {
|
||||||
|
canvasId,
|
||||||
|
nodeId: node.id,
|
||||||
|
nodeType: node.type,
|
||||||
|
intersectedEdgeId,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
overlappedEdgeRef.current = null;
|
||||||
|
setHighlightedIntersectionEdge(null);
|
||||||
isDragging.current = false;
|
isDragging.current = false;
|
||||||
});
|
}
|
||||||
} else {
|
})();
|
||||||
void moveNode({
|
|
||||||
nodeId: node.id as Id<"nodes">,
|
|
||||||
positionX: node.position.x,
|
|
||||||
positionY: node.position.y,
|
|
||||||
}).then(() => {
|
|
||||||
isDragging.current = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[moveNode, batchMoveNodes],
|
[
|
||||||
|
batchMoveNodes,
|
||||||
|
canvasId,
|
||||||
|
createEdge,
|
||||||
|
edges,
|
||||||
|
moveNode,
|
||||||
|
removeEdge,
|
||||||
|
setHighlightedIntersectionEdge,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Neue Verbindung → Convex Edge ────────────────────────────
|
// ─── Neue Verbindung → Convex Edge ────────────────────────────
|
||||||
@@ -419,43 +652,47 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<CanvasPlacementProvider canvasId={canvasId}>
|
||||||
<CanvasToolbar canvasId={canvasId} canvasName={canvas?.name ?? "canvas"} />
|
<div className="relative h-full w-full">
|
||||||
<ReactFlow
|
<CanvasToolbar canvasName={canvas?.name ?? "canvas"} />
|
||||||
nodes={nodes}
|
<ReactFlow
|
||||||
edges={edges}
|
nodes={nodes}
|
||||||
nodeTypes={nodeTypes}
|
edges={edges}
|
||||||
onNodesChange={onNodesChange}
|
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
||||||
onEdgesChange={onEdgesChange}
|
nodeTypes={nodeTypes}
|
||||||
onNodeDragStart={onNodeDragStart}
|
onNodesChange={onNodesChange}
|
||||||
onNodeDragStop={onNodeDragStop}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onNodeDragStart={onNodeDragStart}
|
||||||
onReconnect={onReconnect}
|
onNodeDrag={onNodeDrag}
|
||||||
onReconnectStart={onReconnectStart}
|
onNodeDragStop={onNodeDragStop}
|
||||||
onReconnectEnd={onReconnectEnd}
|
onConnect={onConnect}
|
||||||
onNodesDelete={onNodesDelete}
|
onReconnect={onReconnect}
|
||||||
onEdgesDelete={onEdgesDelete}
|
onReconnectStart={onReconnectStart}
|
||||||
onDragOver={onDragOver}
|
onReconnectEnd={onReconnectEnd}
|
||||||
onDrop={onDrop}
|
onNodesDelete={onNodesDelete}
|
||||||
fitView
|
onEdgesDelete={onEdgesDelete}
|
||||||
snapToGrid
|
onDragOver={onDragOver}
|
||||||
snapGrid={[16, 16]}
|
onDrop={onDrop}
|
||||||
deleteKeyCode={["Backspace", "Delete"]}
|
fitView
|
||||||
multiSelectionKeyCode="Shift"
|
snapToGrid
|
||||||
proOptions={{ hideAttribution: true }}
|
snapGrid={[16, 16]}
|
||||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
deleteKeyCode={["Backspace", "Delete"]}
|
||||||
className="bg-background"
|
multiSelectionKeyCode="Shift"
|
||||||
>
|
proOptions={{ hideAttribution: true }}
|
||||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||||
<Controls className="bg-card! border! shadow-sm! rounded-lg!" />
|
className="bg-background"
|
||||||
<MiniMap
|
>
|
||||||
className="bg-card! border! shadow-sm! rounded-lg!"
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||||
nodeColor={getMiniMapNodeColor}
|
<Controls className="bg-card! border! shadow-sm! rounded-lg!" />
|
||||||
nodeStrokeColor={getMiniMapNodeStrokeColor}
|
<MiniMap
|
||||||
maskColor="rgba(0, 0, 0, 0.1)"
|
className="bg-card! border! shadow-sm! rounded-lg!"
|
||||||
/>
|
nodeColor={getMiniMapNodeColor}
|
||||||
</ReactFlow>
|
nodeStrokeColor={getMiniMapNodeStrokeColor}
|
||||||
</div>
|
maskColor="rgba(0, 0, 0, 0.1)"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
</CanvasPlacementProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
|||||||
style={{ top: "55%" }}
|
style={{ top: "55%" }}
|
||||||
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
|
||||||
/>
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="compare-out"
|
||||||
|
className="!h-3 !w-3 !border-2 !border-background !bg-muted-foreground"
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { type NodeProps, type Node } from "@xyflow/react";
|
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||||
import { useMutation } from "convex/react";
|
import { useMutation } from "convex/react";
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
@@ -22,6 +22,7 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setLabel(data.label ?? "Gruppe");
|
setLabel(data.label ?? "Gruppe");
|
||||||
}
|
}
|
||||||
}, [data.label, isEditing]);
|
}, [data.label, isEditing]);
|
||||||
@@ -46,6 +47,12 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
|
|||||||
selected={selected}
|
selected={selected}
|
||||||
className="min-w-[200px] min-h-[150px] p-3 border-dashed"
|
className="min-w-[200px] min-h-[150px] p-3 border-dashed"
|
||||||
>
|
>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!h-3 !w-3 !bg-muted-foreground !border-2 !border-background"
|
||||||
|
/>
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<input
|
<input
|
||||||
value={label}
|
value={label}
|
||||||
@@ -63,6 +70,12 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
|
|||||||
📁 {label}
|
📁 {label}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-3 !w-3 !bg-muted-foreground !border-2 !border-background"
|
||||||
|
/>
|
||||||
</BaseNodeWrapper>
|
</BaseNodeWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNodeWrapper selected={selected} status={data._status}>
|
<BaseNodeWrapper selected={selected} status={data._status}>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="h-3! w-3! bg-primary! border-2! border-background!"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<div className="mb-1 flex items-center justify-between">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<div className="text-xs font-medium text-muted-foreground">🖼️ Bild</div>
|
<div className="text-xs font-medium text-muted-foreground">🖼️ Bild</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { type NodeProps, type Node } from "@xyflow/react";
|
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||||
import { useMutation } from "convex/react";
|
import { useMutation } from "convex/react";
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
@@ -23,6 +23,7 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setContent(data.content ?? "");
|
setContent(data.content ?? "");
|
||||||
}
|
}
|
||||||
}, [data.content, isEditing]);
|
}, [data.content, isEditing]);
|
||||||
@@ -53,6 +54,12 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNodeWrapper selected={selected} className="w-52 p-3">
|
<BaseNodeWrapper selected={selected} className="w-52 p-3">
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
📌 Notiz
|
📌 Notiz
|
||||||
</div>
|
</div>
|
||||||
@@ -78,6 +85,12 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
||||||
|
/>
|
||||||
</BaseNodeWrapper>
|
</BaseNodeWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useMutation, useAction } from "convex/react";
|
|||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import BaseNodeWrapper from "./base-node-wrapper";
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
|
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
||||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||||
import { DEFAULT_MODEL_ID } from "@/lib/ai-models";
|
import { DEFAULT_MODEL_ID } from "@/lib/ai-models";
|
||||||
import {
|
import {
|
||||||
@@ -104,9 +105,9 @@ export default function PromptNode({
|
|||||||
dataRef.current = data;
|
dataRef.current = data;
|
||||||
|
|
||||||
const updateData = useMutation(api.nodes.updateData);
|
const updateData = useMutation(api.nodes.updateData);
|
||||||
const createNode = useMutation(api.nodes.create);
|
|
||||||
const createEdge = useMutation(api.edges.create);
|
const createEdge = useMutation(api.edges.create);
|
||||||
const generateImage = useAction(api.ai.generateImage);
|
const generateImage = useAction(api.ai.generateImage);
|
||||||
|
const { createNodeWithIntersection } = useCanvasPlacement();
|
||||||
|
|
||||||
const debouncedSave = useDebouncedCallback(() => {
|
const debouncedSave = useDebouncedCallback(() => {
|
||||||
const raw = dataRef.current as Record<string, unknown>;
|
const raw = dataRef.current as Record<string, unknown>;
|
||||||
@@ -181,11 +182,9 @@ export default function PromptNode({
|
|||||||
const viewport = getImageViewportSize(aspectRatio);
|
const viewport = getImageViewportSize(aspectRatio);
|
||||||
const outer = getAiImageNodeOuterSize(viewport);
|
const outer = getAiImageNodeOuterSize(viewport);
|
||||||
|
|
||||||
const aiNodeId = await createNode({
|
const aiNodeId = await createNodeWithIntersection({
|
||||||
canvasId,
|
|
||||||
type: "ai-image",
|
type: "ai-image",
|
||||||
positionX: posX,
|
position: { x: posX, y: posY },
|
||||||
positionY: posY,
|
|
||||||
width: outer.width,
|
width: outer.width,
|
||||||
height: outer.height,
|
height: outer.height,
|
||||||
data: {
|
data: {
|
||||||
@@ -229,7 +228,7 @@ export default function PromptNode({
|
|||||||
id,
|
id,
|
||||||
getEdges,
|
getEdges,
|
||||||
getNode,
|
getNode,
|
||||||
createNode,
|
createNodeWithIntersection,
|
||||||
createEdge,
|
createEdge,
|
||||||
generateImage,
|
generateImage,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -75,7 +75,13 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNodeWrapper selected={selected} status={data._status}>
|
<BaseNodeWrapper selected={selected} status={data._status} className="relative">
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="w-64 p-3">
|
<div className="w-64 p-3">
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
📝 Text
|
📝 Text
|
||||||
|
|||||||
@@ -55,12 +55,14 @@ export const NODE_HANDLE_MAP: Record<
|
|||||||
string,
|
string,
|
||||||
{ source?: string; target?: string }
|
{ source?: string; target?: string }
|
||||||
> = {
|
> = {
|
||||||
image: { source: undefined },
|
image: { source: undefined, target: undefined },
|
||||||
text: { source: undefined },
|
text: { source: undefined, target: undefined },
|
||||||
prompt: { source: "prompt-out", target: "image-in" },
|
prompt: { source: "prompt-out", target: "image-in" },
|
||||||
"ai-image": { source: "image-out", target: "prompt-in" },
|
"ai-image": { source: "image-out", target: "prompt-in" },
|
||||||
|
group: { source: undefined, target: undefined },
|
||||||
frame: { source: "frame-out", target: "frame-in" },
|
frame: { source: "frame-out", target: "frame-in" },
|
||||||
compare: { target: "left" },
|
note: { source: undefined, target: undefined },
|
||||||
|
compare: { source: "compare-out", target: "left" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user