feat(canvas): add proximity magnet target resolver
This commit is contained in:
@@ -7,7 +7,6 @@ import { resolveDroppedConnectionTarget } from "@/components/canvas/canvas-helpe
|
|||||||
|
|
||||||
function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode {
|
function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode {
|
||||||
return {
|
return {
|
||||||
id: overrides.id,
|
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {},
|
data: {},
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -40,6 +39,34 @@ function makeNodeElement(id: string, rect: Partial<DOMRect> = {}): HTMLElement {
|
|||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeHandleElement(args: {
|
||||||
|
nodeId: string;
|
||||||
|
handleType: "source" | "target";
|
||||||
|
handleId?: string;
|
||||||
|
rect: Partial<DOMRect>;
|
||||||
|
}): HTMLElement {
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.className = "react-flow__handle";
|
||||||
|
element.dataset.nodeId = args.nodeId;
|
||||||
|
element.dataset.handleType = args.handleType;
|
||||||
|
if (args.handleId !== undefined) {
|
||||||
|
element.dataset.handleId = args.handleId;
|
||||||
|
}
|
||||||
|
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
|
||||||
|
x: args.rect.left ?? 0,
|
||||||
|
y: args.rect.top ?? 0,
|
||||||
|
top: args.rect.top ?? 0,
|
||||||
|
left: args.rect.left ?? 0,
|
||||||
|
right: args.rect.right ?? 10,
|
||||||
|
bottom: args.rect.bottom ?? 10,
|
||||||
|
width: args.rect.width ?? 10,
|
||||||
|
height: args.rect.height ?? 10,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRect);
|
||||||
|
document.body.appendChild(element);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
describe("resolveDroppedConnectionTarget", () => {
|
describe("resolveDroppedConnectionTarget", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -144,6 +171,169 @@ describe("resolveDroppedConnectionTarget", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves nearest valid target handle even without a node body hit", () => {
|
||||||
|
const sourceNode = createNode({
|
||||||
|
id: "node-source",
|
||||||
|
type: "image",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
const compareNode = createNode({
|
||||||
|
id: "node-compare",
|
||||||
|
type: "compare",
|
||||||
|
position: { x: 320, y: 200 },
|
||||||
|
});
|
||||||
|
Object.defineProperty(document, "elementsFromPoint", {
|
||||||
|
value: vi.fn(() => []),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-compare",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "left",
|
||||||
|
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
|
||||||
|
});
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-compare",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "right",
|
||||||
|
rect: { left: 438, top: 332, width: 12, height: 12, right: 450, bottom: 344 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = resolveDroppedConnectionTarget({
|
||||||
|
point: { x: 364, y: 258 },
|
||||||
|
fromNodeId: "node-source",
|
||||||
|
fromHandleType: "source",
|
||||||
|
nodes: [sourceNode, compareNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
sourceNodeId: "node-source",
|
||||||
|
targetNodeId: "node-compare",
|
||||||
|
sourceHandle: undefined,
|
||||||
|
targetHandle: "left",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a closer invalid handle and picks the nearest valid handle", () => {
|
||||||
|
const sourceNode = createNode({
|
||||||
|
id: "node-source",
|
||||||
|
type: "image",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
const mixerNode = createNode({
|
||||||
|
id: "node-mixer",
|
||||||
|
type: "mixer",
|
||||||
|
position: { x: 320, y: 200 },
|
||||||
|
});
|
||||||
|
Object.defineProperty(document, "elementsFromPoint", {
|
||||||
|
value: vi.fn(() => []),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-mixer",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "base",
|
||||||
|
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
|
||||||
|
});
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-mixer",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "overlay",
|
||||||
|
rect: { left: 386, top: 278, width: 12, height: 12, right: 398, bottom: 290 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = resolveDroppedConnectionTarget({
|
||||||
|
point: { x: 364, y: 258 },
|
||||||
|
fromNodeId: "node-source",
|
||||||
|
fromHandleType: "source",
|
||||||
|
nodes: [sourceNode, mixerNode],
|
||||||
|
edges: [
|
||||||
|
createEdge({
|
||||||
|
id: "edge-base-taken",
|
||||||
|
source: "node-source",
|
||||||
|
target: "node-mixer",
|
||||||
|
targetHandle: "base",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
sourceNodeId: "node-source",
|
||||||
|
targetNodeId: "node-mixer",
|
||||||
|
sourceHandle: undefined,
|
||||||
|
targetHandle: "overlay",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the actually nearest handle for compare and mixer targets", () => {
|
||||||
|
const sourceNode = createNode({
|
||||||
|
id: "node-source",
|
||||||
|
type: "image",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
const compareNode = createNode({
|
||||||
|
id: "node-compare",
|
||||||
|
type: "compare",
|
||||||
|
position: { x: 320, y: 200 },
|
||||||
|
});
|
||||||
|
const mixerNode = createNode({
|
||||||
|
id: "node-mixer",
|
||||||
|
type: "mixer",
|
||||||
|
position: { x: 640, y: 200 },
|
||||||
|
});
|
||||||
|
Object.defineProperty(document, "elementsFromPoint", {
|
||||||
|
value: vi.fn(() => []),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-compare",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "left",
|
||||||
|
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
|
||||||
|
});
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-compare",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "right",
|
||||||
|
rect: { left: 438, top: 332, width: 12, height: 12, right: 450, bottom: 344 },
|
||||||
|
});
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-mixer",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "base",
|
||||||
|
rect: { left: 678, top: 252, width: 12, height: 12, right: 690, bottom: 264 },
|
||||||
|
});
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-mixer",
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "overlay",
|
||||||
|
rect: { left: 678, top: 292, width: 12, height: 12, right: 690, bottom: 304 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const compareResult = resolveDroppedConnectionTarget({
|
||||||
|
point: { x: 364, y: 258 },
|
||||||
|
fromNodeId: "node-source",
|
||||||
|
fromHandleType: "source",
|
||||||
|
nodes: [sourceNode, compareNode, mixerNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mixerResult = resolveDroppedConnectionTarget({
|
||||||
|
point: { x: 684, y: 299 },
|
||||||
|
fromNodeId: "node-source",
|
||||||
|
fromHandleType: "source",
|
||||||
|
nodes: [sourceNode, compareNode, mixerNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compareResult?.targetHandle).toBe("left");
|
||||||
|
expect(mixerResult?.targetHandle).toBe("overlay");
|
||||||
|
});
|
||||||
|
|
||||||
it("reverses the connection when the drag starts from a target handle", () => {
|
it("reverses the connection when the drag starts from a target handle", () => {
|
||||||
const droppedNode = createNode({
|
const droppedNode = createNode({
|
||||||
id: "node-dropped",
|
id: "node-dropped",
|
||||||
@@ -177,4 +367,44 @@ describe("resolveDroppedConnectionTarget", () => {
|
|||||||
targetHandle: "target-handle",
|
targetHandle: "target-handle",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves nearest source handle when drag starts from target handle", () => {
|
||||||
|
const fromNode = createNode({
|
||||||
|
id: "node-compare-target",
|
||||||
|
type: "compare",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
const compareNode = createNode({
|
||||||
|
id: "node-compare-source",
|
||||||
|
type: "compare",
|
||||||
|
position: { x: 320, y: 200 },
|
||||||
|
});
|
||||||
|
Object.defineProperty(document, "elementsFromPoint", {
|
||||||
|
value: vi.fn(() => []),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
makeHandleElement({
|
||||||
|
nodeId: "node-compare-source",
|
||||||
|
handleType: "source",
|
||||||
|
handleId: "compare-out",
|
||||||
|
rect: { left: 478, top: 288, width: 12, height: 12, right: 490, bottom: 300 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = resolveDroppedConnectionTarget({
|
||||||
|
point: { x: 484, y: 294 },
|
||||||
|
fromNodeId: "node-compare-target",
|
||||||
|
fromHandleId: "left",
|
||||||
|
fromHandleType: "target",
|
||||||
|
nodes: [fromNode, compareNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
sourceNodeId: "node-compare-source",
|
||||||
|
targetNodeId: "node-compare-target",
|
||||||
|
sourceHandle: "compare-out",
|
||||||
|
targetHandle: "left",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
198
components/canvas/canvas-connection-magnetism.ts
Normal file
198
components/canvas/canvas-connection-magnetism.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import type { Connection, Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||||
|
|
||||||
|
import { validateCanvasConnectionPolicy } from "@/lib/canvas-connection-policy";
|
||||||
|
|
||||||
|
export const HANDLE_GLOW_RADIUS_PX = 56;
|
||||||
|
export const HANDLE_SNAP_RADIUS_PX = 40;
|
||||||
|
|
||||||
|
export type CanvasMagnetTarget = {
|
||||||
|
nodeId: string;
|
||||||
|
handleId?: string;
|
||||||
|
handleType: "source" | "target";
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
|
distancePx: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HandleCandidate = CanvasMagnetTarget & {
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isOptimisticEdgeId(id: string): boolean {
|
||||||
|
return id.startsWith("optimistic_edge_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHandleId(value: string | undefined): string | undefined {
|
||||||
|
if (value === undefined || value === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidConnectionCandidate(args: {
|
||||||
|
connection: Connection;
|
||||||
|
nodes: RFNode[];
|
||||||
|
edges: RFEdge[];
|
||||||
|
}): boolean {
|
||||||
|
const { connection, nodes, edges } = args;
|
||||||
|
if (!connection.source || !connection.target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (connection.source === connection.target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceNode = nodes.find((node) => node.id === connection.source);
|
||||||
|
const targetNode = nodes.find((node) => node.id === connection.target);
|
||||||
|
if (!sourceNode || !targetNode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const incomingEdges = edges.filter(
|
||||||
|
(edge) =>
|
||||||
|
edge.className !== "temp" &&
|
||||||
|
!isOptimisticEdgeId(edge.id) &&
|
||||||
|
edge.target === connection.target,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
validateCanvasConnectionPolicy({
|
||||||
|
sourceType: sourceNode.type ?? "",
|
||||||
|
targetType: targetNode.type ?? "",
|
||||||
|
targetHandle: connection.targetHandle,
|
||||||
|
targetIncomingCount: incomingEdges.length,
|
||||||
|
targetIncomingHandles: incomingEdges.map((edge) => edge.targetHandle),
|
||||||
|
}) === null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectHandleCandidates(args: {
|
||||||
|
point: { x: number; y: number };
|
||||||
|
expectedHandleType: "source" | "target";
|
||||||
|
maxDistancePx: number;
|
||||||
|
handleElements?: Element[];
|
||||||
|
}): HandleCandidate[] {
|
||||||
|
const { point, expectedHandleType, maxDistancePx } = args;
|
||||||
|
const handleElements =
|
||||||
|
args.handleElements ??
|
||||||
|
(typeof document === "undefined"
|
||||||
|
? []
|
||||||
|
: Array.from(document.querySelectorAll("[data-node-id][data-handle-type]")));
|
||||||
|
|
||||||
|
const candidates: HandleCandidate[] = [];
|
||||||
|
let index = 0;
|
||||||
|
for (const element of handleElements) {
|
||||||
|
if (!(element instanceof HTMLElement)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (element.dataset.handleType !== expectedHandleType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeId = element.dataset.nodeId;
|
||||||
|
if (!nodeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const centerX = rect.left + rect.width / 2;
|
||||||
|
const centerY = rect.top + rect.height / 2;
|
||||||
|
const distancePx = Math.hypot(point.x - centerX, point.y - centerY);
|
||||||
|
|
||||||
|
if (distancePx > maxDistancePx) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
index,
|
||||||
|
nodeId,
|
||||||
|
handleId: normalizeHandleId(element.dataset.handleId),
|
||||||
|
handleType: expectedHandleType,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
distancePx,
|
||||||
|
});
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toConnectionFromCandidate(args: {
|
||||||
|
fromNodeId: string;
|
||||||
|
fromHandleId?: string;
|
||||||
|
fromHandleType: "source" | "target";
|
||||||
|
candidate: HandleCandidate;
|
||||||
|
}): Connection {
|
||||||
|
if (args.fromHandleType === "source") {
|
||||||
|
return {
|
||||||
|
source: args.fromNodeId,
|
||||||
|
sourceHandle: args.fromHandleId ?? null,
|
||||||
|
target: args.candidate.nodeId,
|
||||||
|
targetHandle: args.candidate.handleId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: args.candidate.nodeId,
|
||||||
|
sourceHandle: args.candidate.handleId ?? null,
|
||||||
|
target: args.fromNodeId,
|
||||||
|
targetHandle: args.fromHandleId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCanvasMagnetTarget(args: {
|
||||||
|
point: { x: number; y: number };
|
||||||
|
fromNodeId: string;
|
||||||
|
fromHandleId?: string;
|
||||||
|
fromHandleType: "source" | "target";
|
||||||
|
nodes: RFNode[];
|
||||||
|
edges: RFEdge[];
|
||||||
|
maxDistancePx?: number;
|
||||||
|
handleElements?: Element[];
|
||||||
|
}): CanvasMagnetTarget | null {
|
||||||
|
const expectedHandleType = args.fromHandleType === "source" ? "target" : "source";
|
||||||
|
const maxDistancePx = args.maxDistancePx ?? HANDLE_GLOW_RADIUS_PX;
|
||||||
|
|
||||||
|
const candidates = collectHandleCandidates({
|
||||||
|
point: args.point,
|
||||||
|
expectedHandleType,
|
||||||
|
maxDistancePx,
|
||||||
|
handleElements: args.handleElements,
|
||||||
|
}).filter((candidate) => {
|
||||||
|
const connection = toConnectionFromCandidate({
|
||||||
|
fromNodeId: args.fromNodeId,
|
||||||
|
fromHandleId: args.fromHandleId,
|
||||||
|
fromHandleType: args.fromHandleType,
|
||||||
|
candidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return isValidConnectionCandidate({
|
||||||
|
connection,
|
||||||
|
nodes: args.nodes,
|
||||||
|
edges: args.edges,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const distanceDelta = a.distancePx - b.distancePx;
|
||||||
|
if (Math.abs(distanceDelta) > Number.EPSILON) {
|
||||||
|
return distanceDelta;
|
||||||
|
}
|
||||||
|
return a.index - b.index;
|
||||||
|
});
|
||||||
|
|
||||||
|
const winner = candidates[0];
|
||||||
|
return {
|
||||||
|
nodeId: winner.nodeId,
|
||||||
|
handleId: winner.handleId,
|
||||||
|
handleType: winner.handleType,
|
||||||
|
centerX: winner.centerX,
|
||||||
|
centerY: winner.centerY,
|
||||||
|
distancePx: winner.distancePx,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getSourceImageFromGraph,
|
getSourceImageFromGraph,
|
||||||
} from "@/lib/canvas-render-preview";
|
} from "@/lib/canvas-render-preview";
|
||||||
import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||||
|
import { resolveCanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism";
|
||||||
|
|
||||||
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
||||||
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
||||||
@@ -421,17 +422,7 @@ export function resolveDroppedConnectionTarget(args: {
|
|||||||
? []
|
? []
|
||||||
: document.elementsFromPoint(args.point.x, args.point.y);
|
: document.elementsFromPoint(args.point.x, args.point.y);
|
||||||
const nodeElement = getNodeElementAtClientPoint(args.point, elementsAtPoint);
|
const nodeElement = getNodeElementAtClientPoint(args.point, elementsAtPoint);
|
||||||
if (!nodeElement) {
|
if (nodeElement) {
|
||||||
logCanvasConnectionDebug("drop-target:node-missed", {
|
|
||||||
point: args.point,
|
|
||||||
fromNodeId: args.fromNodeId,
|
|
||||||
fromHandleId: args.fromHandleId ?? null,
|
|
||||||
fromHandleType: args.fromHandleType,
|
|
||||||
elementsAtPoint: elementsAtPoint.slice(0, 6).map(describeConnectionDebugElement),
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetNodeId = nodeElement.dataset.id;
|
const targetNodeId = nodeElement.dataset.id;
|
||||||
if (!targetNodeId) {
|
if (!targetNodeId) {
|
||||||
logCanvasConnectionDebug("drop-target:node-missing-data-id", {
|
logCanvasConnectionDebug("drop-target:node-missing-data-id", {
|
||||||
@@ -508,6 +499,65 @@ export function resolveDroppedConnectionTarget(args: {
|
|||||||
resolvedConnection: droppedConnection,
|
resolvedConnection: droppedConnection,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return droppedConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const magnetTarget = resolveCanvasMagnetTarget({
|
||||||
|
point: args.point,
|
||||||
|
fromNodeId: args.fromNodeId,
|
||||||
|
fromHandleId: args.fromHandleId,
|
||||||
|
fromHandleType: args.fromHandleType,
|
||||||
|
nodes: args.nodes,
|
||||||
|
edges: args.edges,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!magnetTarget) {
|
||||||
|
logCanvasConnectionDebug("drop-target:node-missed", {
|
||||||
|
point: args.point,
|
||||||
|
fromNodeId: args.fromNodeId,
|
||||||
|
fromHandleId: args.fromHandleId ?? null,
|
||||||
|
fromHandleType: args.fromHandleType,
|
||||||
|
elementsAtPoint: elementsAtPoint.slice(0, 6).map(describeConnectionDebugElement),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.fromHandleType === "source") {
|
||||||
|
const droppedConnection = {
|
||||||
|
sourceNodeId: args.fromNodeId,
|
||||||
|
targetNodeId: magnetTarget.nodeId,
|
||||||
|
sourceHandle: args.fromHandleId,
|
||||||
|
targetHandle: magnetTarget.handleId,
|
||||||
|
};
|
||||||
|
|
||||||
|
logCanvasConnectionDebug("drop-target:magnet-detected", {
|
||||||
|
point: args.point,
|
||||||
|
fromNodeId: args.fromNodeId,
|
||||||
|
fromHandleId: args.fromHandleId ?? null,
|
||||||
|
fromHandleType: args.fromHandleType,
|
||||||
|
magnetTarget,
|
||||||
|
resolvedConnection: droppedConnection,
|
||||||
|
});
|
||||||
|
|
||||||
|
return droppedConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const droppedConnection = {
|
||||||
|
sourceNodeId: magnetTarget.nodeId,
|
||||||
|
targetNodeId: args.fromNodeId,
|
||||||
|
sourceHandle: magnetTarget.handleId,
|
||||||
|
targetHandle: args.fromHandleId,
|
||||||
|
};
|
||||||
|
|
||||||
|
logCanvasConnectionDebug("drop-target:magnet-detected", {
|
||||||
|
point: args.point,
|
||||||
|
fromNodeId: args.fromNodeId,
|
||||||
|
fromHandleId: args.fromHandleId ?? null,
|
||||||
|
fromHandleType: args.fromHandleType,
|
||||||
|
magnetTarget,
|
||||||
|
resolvedConnection: droppedConnection,
|
||||||
|
});
|
||||||
|
|
||||||
return droppedConnection;
|
return droppedConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ export function convexEdgeToRF(edge: Doc<"edges">): RFEdge {
|
|||||||
* Akzentfarben der Handles je Node-Typ (s. jeweilige Node-Komponente).
|
* Akzentfarben der Handles je Node-Typ (s. jeweilige Node-Komponente).
|
||||||
* Für einen dezenten Glow entlang der Kante (drop-shadow am Pfad).
|
* Für einen dezenten Glow entlang der Kante (drop-shadow am Pfad).
|
||||||
*/
|
*/
|
||||||
const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> = {
|
type RgbColor = readonly [number, number, number];
|
||||||
|
|
||||||
|
const SOURCE_NODE_GLOW_RGB: Record<string, RgbColor> = {
|
||||||
prompt: [139, 92, 246],
|
prompt: [139, 92, 246],
|
||||||
"video-prompt": [124, 58, 237],
|
"video-prompt": [124, 58, 237],
|
||||||
"ai-image": [139, 92, 246],
|
"ai-image": [139, 92, 246],
|
||||||
@@ -123,21 +125,59 @@ const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> =
|
|||||||
render: [14, 165, 233],
|
render: [14, 165, 233],
|
||||||
agent: [245, 158, 11],
|
agent: [245, 158, 11],
|
||||||
"agent-output": [245, 158, 11],
|
"agent-output": [245, 158, 11],
|
||||||
|
mixer: [100, 116, 139],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */
|
/** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */
|
||||||
const COMPARE_HANDLE_CONNECTION_RGB: Record<
|
const COMPARE_HANDLE_CONNECTION_RGB: Record<string, RgbColor> = {
|
||||||
string,
|
|
||||||
readonly [number, number, number]
|
|
||||||
> = {
|
|
||||||
left: [59, 130, 246],
|
left: [59, 130, 246],
|
||||||
right: [16, 185, 129],
|
right: [16, 185, 129],
|
||||||
"compare-out": [100, 116, 139],
|
"compare-out": [100, 116, 139],
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [
|
const MIXER_HANDLE_CONNECTION_RGB: Record<string, RgbColor> = {
|
||||||
13, 148, 136,
|
base: [14, 165, 233],
|
||||||
];
|
overlay: [236, 72, 153],
|
||||||
|
"mixer-out": [100, 116, 139],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONNECTION_LINE_FALLBACK_RGB: RgbColor = [13, 148, 136];
|
||||||
|
|
||||||
|
export function canvasHandleAccentRgb(args: {
|
||||||
|
nodeType: string | undefined;
|
||||||
|
handleId?: string | null;
|
||||||
|
handleType: "source" | "target";
|
||||||
|
}): RgbColor {
|
||||||
|
const nodeType = args.nodeType;
|
||||||
|
const handleId = args.handleId ?? undefined;
|
||||||
|
const handleType = args.handleType;
|
||||||
|
|
||||||
|
if (nodeType === "compare" && handleId) {
|
||||||
|
if (handleType === "target" && handleId === "compare-out") {
|
||||||
|
return SOURCE_NODE_GLOW_RGB.compare;
|
||||||
|
}
|
||||||
|
const byHandle = COMPARE_HANDLE_CONNECTION_RGB[handleId];
|
||||||
|
if (byHandle) {
|
||||||
|
return byHandle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeType === "mixer" && handleId) {
|
||||||
|
if (handleType === "target" && handleId === "mixer-out") {
|
||||||
|
return SOURCE_NODE_GLOW_RGB.mixer;
|
||||||
|
}
|
||||||
|
const byHandle = MIXER_HANDLE_CONNECTION_RGB[handleId];
|
||||||
|
if (byHandle) {
|
||||||
|
return byHandle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodeType) {
|
||||||
|
return CONNECTION_LINE_FALLBACK_RGB;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect).
|
* RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect).
|
||||||
@@ -145,13 +185,12 @@ const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [
|
|||||||
export function connectionLineAccentRgb(
|
export function connectionLineAccentRgb(
|
||||||
nodeType: string | undefined,
|
nodeType: string | undefined,
|
||||||
handleId: string | null | undefined,
|
handleId: string | null | undefined,
|
||||||
): readonly [number, number, number] {
|
): RgbColor {
|
||||||
if (nodeType === "compare" && handleId) {
|
return canvasHandleAccentRgb({
|
||||||
const byHandle = COMPARE_HANDLE_CONNECTION_RGB[handleId];
|
nodeType,
|
||||||
if (byHandle) return byHandle;
|
handleId,
|
||||||
}
|
handleType: "source",
|
||||||
if (!nodeType) return CONNECTION_LINE_FALLBACK_RGB;
|
});
|
||||||
return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EdgeGlowColorMode = "light" | "dark";
|
export type EdgeGlowColorMode = "light" | "dark";
|
||||||
|
|||||||
Reference in New Issue
Block a user