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";
|
||||
|
||||
import { useMutation } from "convex/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 { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
||||
|
||||
const nodeTemplates = [
|
||||
{
|
||||
@@ -53,15 +51,13 @@ const nodeTemplates = [
|
||||
] as const;
|
||||
|
||||
interface CanvasToolbarProps {
|
||||
canvasId: Id<"canvases">;
|
||||
canvasName?: string;
|
||||
}
|
||||
|
||||
export default function CanvasToolbar({
|
||||
canvasId,
|
||||
canvasName,
|
||||
}: CanvasToolbarProps) {
|
||||
const createNode = useMutation(api.nodes.create);
|
||||
const { createNodeWithIntersection } = useCanvasPlacement();
|
||||
const nodeCountRef = useRef(0);
|
||||
|
||||
const handleAddNode = async (
|
||||
@@ -72,14 +68,12 @@ export default function CanvasToolbar({
|
||||
) => {
|
||||
const offset = (nodeCountRef.current % 8) * 24;
|
||||
nodeCountRef.current += 1;
|
||||
await createNode({
|
||||
canvasId,
|
||||
await createNodeWithIntersection({
|
||||
type,
|
||||
positionX: 100 + offset,
|
||||
positionY: 100 + offset,
|
||||
position: { x: 100 + offset, y: 100 + offset },
|
||||
width,
|
||||
height,
|
||||
data: { ...data, canvasId },
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type NodeChange,
|
||||
type EdgeChange,
|
||||
type Connection,
|
||||
type DefaultEdgeOptions,
|
||||
BackgroundVariant,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
@@ -27,8 +28,14 @@ import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
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 { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
|
||||
|
||||
interface CanvasInnerProps {
|
||||
canvasId: Id<"canvases">;
|
||||
@@ -97,6 +104,68 @@ function getMiniMapNodeStrokeColor(node: RFNode): string {
|
||||
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) {
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { resolvedTheme } = useTheme();
|
||||
@@ -175,6 +244,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
|
||||
// Delete Edge on Drop
|
||||
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 ──────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -269,36 +343,195 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
[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 ────────────────────────────────────────
|
||||
const onNodeDragStart = useCallback(() => {
|
||||
isDragging.current = true;
|
||||
}, []);
|
||||
overlappedEdgeRef.current = null;
|
||||
setHighlightedIntersectionEdge(null);
|
||||
}, [setHighlightedIntersectionEdge]);
|
||||
|
||||
// ─── Drag Stop → Commit zu Convex ─────────────────────────────
|
||||
const onNodeDragStop = useCallback(
|
||||
(_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => {
|
||||
// isDragging bleibt true bis die Mutation resolved ist → kein Convex-Override möglich
|
||||
const intersectedEdgeId = overlappedEdgeRef.current;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
// isDragging bleibt true bis alle Mutations resolved sind
|
||||
if (draggedNodes.length > 1) {
|
||||
void batchMoveNodes({
|
||||
await batchMoveNodes({
|
||||
moves: draggedNodes.map((n) => ({
|
||||
nodeId: n.id as Id<"nodes">,
|
||||
positionX: n.position.x,
|
||||
positionY: n.position.y,
|
||||
})),
|
||||
}).then(() => {
|
||||
isDragging.current = false;
|
||||
});
|
||||
} else {
|
||||
void moveNode({
|
||||
await moveNode({
|
||||
nodeId: node.id as Id<"nodes">,
|
||||
positionX: node.position.x,
|
||||
positionY: node.position.y,
|
||||
}).then(() => {
|
||||
isDragging.current = false;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
})();
|
||||
},
|
||||
[moveNode, batchMoveNodes],
|
||||
[
|
||||
batchMoveNodes,
|
||||
canvasId,
|
||||
createEdge,
|
||||
edges,
|
||||
moveNode,
|
||||
removeEdge,
|
||||
setHighlightedIntersectionEdge,
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Neue Verbindung → Convex Edge ────────────────────────────
|
||||
@@ -419,15 +652,18 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<CanvasPlacementProvider canvasId={canvasId}>
|
||||
<div className="relative h-full w-full">
|
||||
<CanvasToolbar canvasId={canvasId} canvasName={canvas?.name ?? "canvas"} />
|
||||
<CanvasToolbar canvasName={canvas?.name ?? "canvas"} />
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onConnect={onConnect}
|
||||
onReconnect={onReconnect}
|
||||
@@ -456,6 +692,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</CanvasPlacementProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,12 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
||||
style={{ top: "55%" }}
|
||||
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
|
||||
ref={containerRef}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
@@ -22,6 +22,7 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLabel(data.label ?? "Gruppe");
|
||||
}
|
||||
}, [data.label, isEditing]);
|
||||
@@ -46,6 +47,12 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
|
||||
selected={selected}
|
||||
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 ? (
|
||||
<input
|
||||
value={label}
|
||||
@@ -63,6 +70,12 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
|
||||
📁 {label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !bg-muted-foreground !border-2 !border-background"
|
||||
/>
|
||||
</BaseNodeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,6 +117,12 @@ export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>)
|
||||
|
||||
return (
|
||||
<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="mb-1 flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-muted-foreground">🖼️ Bild</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
@@ -23,6 +23,7 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setContent(data.content ?? "");
|
||||
}
|
||||
}, [data.content, isEditing]);
|
||||
@@ -53,6 +54,12 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
📌 Notiz
|
||||
</div>
|
||||
@@ -78,6 +85,12 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
||||
/>
|
||||
</BaseNodeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useMutation, useAction } from "convex/react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { DEFAULT_MODEL_ID } from "@/lib/ai-models";
|
||||
import {
|
||||
@@ -104,9 +105,9 @@ export default function PromptNode({
|
||||
dataRef.current = data;
|
||||
|
||||
const updateData = useMutation(api.nodes.updateData);
|
||||
const createNode = useMutation(api.nodes.create);
|
||||
const createEdge = useMutation(api.edges.create);
|
||||
const generateImage = useAction(api.ai.generateImage);
|
||||
const { createNodeWithIntersection } = useCanvasPlacement();
|
||||
|
||||
const debouncedSave = useDebouncedCallback(() => {
|
||||
const raw = dataRef.current as Record<string, unknown>;
|
||||
@@ -181,11 +182,9 @@ export default function PromptNode({
|
||||
const viewport = getImageViewportSize(aspectRatio);
|
||||
const outer = getAiImageNodeOuterSize(viewport);
|
||||
|
||||
const aiNodeId = await createNode({
|
||||
canvasId,
|
||||
const aiNodeId = await createNodeWithIntersection({
|
||||
type: "ai-image",
|
||||
positionX: posX,
|
||||
positionY: posY,
|
||||
position: { x: posX, y: posY },
|
||||
width: outer.width,
|
||||
height: outer.height,
|
||||
data: {
|
||||
@@ -229,7 +228,7 @@ export default function PromptNode({
|
||||
id,
|
||||
getEdges,
|
||||
getNode,
|
||||
createNode,
|
||||
createNodeWithIntersection,
|
||||
createEdge,
|
||||
generateImage,
|
||||
]);
|
||||
|
||||
@@ -75,7 +75,13 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
||||
);
|
||||
|
||||
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="text-xs font-medium text-muted-foreground mb-1">
|
||||
📝 Text
|
||||
|
||||
@@ -55,12 +55,14 @@ export const NODE_HANDLE_MAP: Record<
|
||||
string,
|
||||
{ source?: string; target?: string }
|
||||
> = {
|
||||
image: { source: undefined },
|
||||
text: { source: undefined },
|
||||
image: { source: undefined, target: undefined },
|
||||
text: { source: undefined, target: undefined },
|
||||
prompt: { source: "prompt-out", target: "image-in" },
|
||||
"ai-image": { source: "image-out", target: "prompt-in" },
|
||||
group: { source: undefined, target: undefined },
|
||||
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