refactor: modularize canvas component by extracting low-level logic into dedicated helper modules
- Removed unused imports and functions from canvas.tsx to streamline the codebase. - Introduced several helper modules for improved organization and maintainability, including canvas-helpers, canvas-node-change-helpers, and canvas-media-utils. - Updated documentation in CLAUDE.md to reflect changes in the canvas architecture and the purpose of new internal modules.
This commit is contained in:
@@ -10,15 +10,28 @@ Der Canvas ist das Herzstück von LemonSpace. Er basiert auf `@xyflow/react` (Re
|
|||||||
app/(app)/canvas/[canvasId]/page.tsx
|
app/(app)/canvas/[canvasId]/page.tsx
|
||||||
└── <Canvas canvasId={...} /> ← components/canvas/canvas.tsx
|
└── <Canvas canvasId={...} /> ← components/canvas/canvas.tsx
|
||||||
├── <ReactFlowProvider>
|
├── <ReactFlowProvider>
|
||||||
│ └── <CanvasInner> ← Haupt-Komponente (2800 Zeilen)
|
│ └── <CanvasInner> ← Haupt-Komponente (~1800 Zeilen)
|
||||||
│ ├── Convex useQuery ← Realtime-Sync
|
│ ├── Convex useQuery ← Realtime-Sync
|
||||||
│ ├── nodeTypes Map ← node-types.ts
|
│ ├── nodeTypes Map ← node-types.ts
|
||||||
│ ├── localStorage Cache ← canvas-local-persistence.ts
|
│ ├── localStorage Cache ← canvas-local-persistence.ts
|
||||||
|
│ ├── Interaction-Hooks ← canvas-*.ts Helper
|
||||||
│ └── Panel-Komponenten
|
│ └── Panel-Komponenten
|
||||||
└── Context Providers
|
└── Context Providers
|
||||||
```
|
```
|
||||||
|
|
||||||
**`canvas.tsx`** ist die zentrale Datei. Sie enthält die gesamte State-Management-Logik, Convex-Mutations, Optimistic Updates und Event-Handler. Sehr groß — vor Änderungen immer den genauen Abschnitt lesen.
|
**`canvas.tsx`** ist weiterhin die zentrale Orchestrierungsdatei. Viel Low-Level-Logik wurde in dedizierte Module ausgelagert, aber Mutations-Flow, Event-Wiring und Render-Composition liegen weiterhin hier.
|
||||||
|
|
||||||
|
### Interne Module von `canvas.tsx`
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|------|-------|
|
||||||
|
| `canvas-helpers.ts` | Shared Utility-Layer (Optimistic IDs, Node-Merge, Compare-Resolution, Edge/Hit-Helpers, Konstante Defaults) |
|
||||||
|
| `canvas-node-change-helpers.ts` | Dimensions-/Resize-Transformationen für `asset` und `ai-image` Nodes |
|
||||||
|
| `canvas-generation-failures.ts` | Hook für AI-Generation-Error-Tracking mit Schwellenwert-Toast |
|
||||||
|
| `canvas-scissors.ts` | Hook für Scherenmodus (K/Esc Toggle, Click-Cut, Stroke-Cut) |
|
||||||
|
| `canvas-delete-handlers.ts` | Hook für `onBeforeDelete`, `onNodesDelete`, `onEdgesDelete` inkl. Bridge-Edges |
|
||||||
|
| `canvas-reconnect.ts` | Hook für Edge-Reconnect (`onReconnectStart`, `onReconnect`, `onReconnectEnd`) |
|
||||||
|
| `canvas-media-utils.ts` | Media-Helfer wie `getImageDimensions(file)` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
185
components/canvas/canvas-delete-handlers.ts
Normal file
185
components/canvas/canvas-delete-handlers.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||||
|
import {
|
||||||
|
getConnectedEdges,
|
||||||
|
type Edge as RFEdge,
|
||||||
|
type Node as RFNode,
|
||||||
|
type OnBeforeDelete,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
import { computeBridgeCreatesForDeletedNodes } from "@/lib/canvas-utils";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
|
||||||
|
|
||||||
|
import { getNodeDeleteBlockReason, isOptimisticEdgeId } from "./canvas-helpers";
|
||||||
|
|
||||||
|
type UseCanvasDeleteHandlersParams = {
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
nodes: RFNode[];
|
||||||
|
edges: RFEdge[];
|
||||||
|
deletingNodeIds: MutableRefObject<Set<string>>;
|
||||||
|
setAssetBrowserTargetNodeId: Dispatch<SetStateAction<string | null>>;
|
||||||
|
runBatchRemoveNodesMutation: (args: { nodeIds: Id<"nodes">[] }) => Promise<unknown>;
|
||||||
|
runCreateEdgeMutation: (args: {
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
sourceNodeId: Id<"nodes">;
|
||||||
|
targetNodeId: Id<"nodes">;
|
||||||
|
sourceHandle?: string;
|
||||||
|
targetHandle?: string;
|
||||||
|
}) => Promise<unknown>;
|
||||||
|
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCanvasDeleteHandlers({
|
||||||
|
canvasId,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
deletingNodeIds,
|
||||||
|
setAssetBrowserTargetNodeId,
|
||||||
|
runBatchRemoveNodesMutation,
|
||||||
|
runCreateEdgeMutation,
|
||||||
|
runRemoveEdgeMutation,
|
||||||
|
}: UseCanvasDeleteHandlersParams): {
|
||||||
|
onBeforeDelete: OnBeforeDelete<RFNode, RFEdge>;
|
||||||
|
onNodesDelete: (deletedNodes: RFNode[]) => void;
|
||||||
|
onEdgesDelete: (deletedEdges: RFEdge[]) => void;
|
||||||
|
} {
|
||||||
|
const onBeforeDelete = useCallback(
|
||||||
|
async ({
|
||||||
|
nodes: matchingNodes,
|
||||||
|
edges: matchingEdges,
|
||||||
|
}: {
|
||||||
|
nodes: RFNode[];
|
||||||
|
edges: RFEdge[];
|
||||||
|
}) => {
|
||||||
|
if (matchingNodes.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed: RFNode[] = [];
|
||||||
|
const blocked: RFNode[] = [];
|
||||||
|
const blockedReasons = new Set<CanvasNodeDeleteBlockReason>();
|
||||||
|
for (const node of matchingNodes) {
|
||||||
|
const reason = getNodeDeleteBlockReason(node);
|
||||||
|
if (reason !== null) {
|
||||||
|
blocked.push(node);
|
||||||
|
blockedReasons.add(reason);
|
||||||
|
} else {
|
||||||
|
allowed.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowed.length === 0) {
|
||||||
|
const { title, desc } = msg.canvas.nodeDeleteBlockedExplain(blockedReasons);
|
||||||
|
toast.warning(title, desc);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocked.length > 0) {
|
||||||
|
const { title, desc } = msg.canvas.nodeDeleteBlockedPartial(
|
||||||
|
blocked.length,
|
||||||
|
blockedReasons,
|
||||||
|
);
|
||||||
|
toast.warning(title, desc);
|
||||||
|
return {
|
||||||
|
nodes: allowed,
|
||||||
|
edges: getConnectedEdges(allowed, matchingEdges),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onNodesDelete = useCallback(
|
||||||
|
(deletedNodes: RFNode[]) => {
|
||||||
|
const count = deletedNodes.length;
|
||||||
|
if (count === 0) return;
|
||||||
|
|
||||||
|
const idsToDelete = deletedNodes.map((node) => node.id);
|
||||||
|
for (const id of idsToDelete) {
|
||||||
|
deletingNodeIds.current.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedTargetSet = new Set(idsToDelete);
|
||||||
|
setAssetBrowserTargetNodeId((current) =>
|
||||||
|
current !== null && removedTargetSet.has(current) ? null : current,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bridgeCreates = computeBridgeCreatesForDeletedNodes(
|
||||||
|
deletedNodes,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
);
|
||||||
|
const edgePromises = bridgeCreates.map((bridgeCreate) =>
|
||||||
|
runCreateEdgeMutation({
|
||||||
|
canvasId,
|
||||||
|
sourceNodeId: bridgeCreate.sourceNodeId,
|
||||||
|
targetNodeId: bridgeCreate.targetNodeId,
|
||||||
|
sourceHandle: bridgeCreate.sourceHandle,
|
||||||
|
targetHandle: bridgeCreate.targetHandle,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
void Promise.all([
|
||||||
|
runBatchRemoveNodesMutation({
|
||||||
|
nodeIds: idsToDelete as Id<"nodes">[],
|
||||||
|
}),
|
||||||
|
...edgePromises,
|
||||||
|
])
|
||||||
|
.then(() => {
|
||||||
|
for (const id of idsToDelete) {
|
||||||
|
deletingNodeIds.current.delete(id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error("[Canvas] batch remove failed", error);
|
||||||
|
for (const id of idsToDelete) {
|
||||||
|
deletingNodeIds.current.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { title } = msg.canvas.nodesRemoved(count);
|
||||||
|
toast.info(title);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
canvasId,
|
||||||
|
deletingNodeIds,
|
||||||
|
edges,
|
||||||
|
nodes,
|
||||||
|
runBatchRemoveNodesMutation,
|
||||||
|
runCreateEdgeMutation,
|
||||||
|
setAssetBrowserTargetNodeId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onEdgesDelete = useCallback(
|
||||||
|
(deletedEdges: RFEdge[]) => {
|
||||||
|
for (const edge of deletedEdges) {
|
||||||
|
if (edge.className === "temp") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isOptimisticEdgeId(edge.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch(
|
||||||
|
(error) => {
|
||||||
|
console.error("[Canvas edge remove failed] edge delete", {
|
||||||
|
edgeId: edge.id,
|
||||||
|
edgeClassName: edge.className ?? null,
|
||||||
|
source: edge.source,
|
||||||
|
target: edge.target,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[runRemoveEdgeMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { onBeforeDelete, onNodesDelete, onEdgesDelete };
|
||||||
|
}
|
||||||
70
components/canvas/canvas-generation-failures.ts
Normal file
70
components/canvas/canvas-generation-failures.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import type { Doc } from "@/convex/_generated/dataModel";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import { msg } from "@/lib/toast-messages";
|
||||||
|
|
||||||
|
import {
|
||||||
|
GENERATION_FAILURE_THRESHOLD,
|
||||||
|
GENERATION_FAILURE_WINDOW_MS,
|
||||||
|
} from "./canvas-helpers";
|
||||||
|
|
||||||
|
export function useGenerationFailureWarnings(
|
||||||
|
convexNodes: Doc<"nodes">[] | undefined,
|
||||||
|
): void {
|
||||||
|
const recentGenerationFailureTimestampsRef = useRef<number[]>([]);
|
||||||
|
const previousNodeStatusRef = useRef<Map<string, string | undefined>>(new Map());
|
||||||
|
const hasInitializedGenerationFailureTrackingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!convexNodes) return;
|
||||||
|
|
||||||
|
const nextNodeStatusMap = new Map<string, string | undefined>();
|
||||||
|
let detectedGenerationFailures = 0;
|
||||||
|
|
||||||
|
for (const node of convexNodes) {
|
||||||
|
nextNodeStatusMap.set(node._id, node.status);
|
||||||
|
|
||||||
|
if (node.type !== "ai-image") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousStatus = previousNodeStatusRef.current.get(node._id);
|
||||||
|
if (
|
||||||
|
hasInitializedGenerationFailureTrackingRef.current &&
|
||||||
|
node.status === "error" &&
|
||||||
|
previousStatus !== "error"
|
||||||
|
) {
|
||||||
|
detectedGenerationFailures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousNodeStatusRef.current = nextNodeStatusMap;
|
||||||
|
|
||||||
|
if (!hasInitializedGenerationFailureTrackingRef.current) {
|
||||||
|
hasInitializedGenerationFailureTrackingRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectedGenerationFailures === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const recentFailures = recentGenerationFailureTimestampsRef.current.filter(
|
||||||
|
(timestamp) => now - timestamp <= GENERATION_FAILURE_WINDOW_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let index = 0; index < detectedGenerationFailures; index += 1) {
|
||||||
|
recentFailures.push(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentFailures.length >= GENERATION_FAILURE_THRESHOLD) {
|
||||||
|
toast.warning(msg.ai.openrouterIssues.title, msg.ai.openrouterIssues.desc);
|
||||||
|
recentGenerationFailureTimestampsRef.current = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recentGenerationFailureTimestampsRef.current = recentFailures;
|
||||||
|
}, [convexNodes]);
|
||||||
|
}
|
||||||
529
components/canvas/canvas-helpers.ts
Normal file
529
components/canvas/canvas-helpers.ts
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
import type { DefaultEdgeOptions, Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||||
|
|
||||||
|
import { readCanvasOps } from "@/lib/canvas-local-persistence";
|
||||||
|
import type { Doc, Id } from "@/convex/_generated/dataModel";
|
||||||
|
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
|
||||||
|
|
||||||
|
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
||||||
|
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
||||||
|
|
||||||
|
export function createCanvasOpId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `op_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @xyflow/react default minZoom ist 0.5 — dreimal weiter raus für große Boards. */
|
||||||
|
export const CANVAS_MIN_ZOOM = 0.5 / 3;
|
||||||
|
|
||||||
|
export function isOptimisticNodeId(id: string): boolean {
|
||||||
|
return id.startsWith(OPTIMISTIC_NODE_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOptimisticEdgeId(id: string): boolean {
|
||||||
|
return id.startsWith(OPTIMISTIC_EDGE_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clientRequestIdFromOptimisticNodeId(id: string): string | null {
|
||||||
|
if (!isOptimisticNodeId(id)) return null;
|
||||||
|
const suffix = id.slice(OPTIMISTIC_NODE_PREFIX.length);
|
||||||
|
return suffix.length > 0 ? suffix : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entspricht `optimistic_edge_${clientRequestId}` im createNodeWithEdge*-Optimistic-Update. */
|
||||||
|
export function clientRequestIdFromOptimisticEdgeId(id: string): string | null {
|
||||||
|
if (!isOptimisticEdgeId(id)) return null;
|
||||||
|
const suffix = id.slice(OPTIMISTIC_EDGE_PREFIX.length);
|
||||||
|
return suffix.length > 0 ? suffix : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gleiche Handle-Normalisierung wie bei convexEdgeToRF — für Signatur-Vergleich/Carry-over. */
|
||||||
|
function sanitizeHandleForEdgeSignature(h: string | null | undefined): string {
|
||||||
|
if (h === undefined || h === null || h === "null") return "";
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rfEdgeConnectionSignature(edge: RFEdge): string {
|
||||||
|
return `${edge.source}|${edge.target}|${sanitizeHandleForEdgeSignature(edge.sourceHandle)}|${sanitizeHandleForEdgeSignature(edge.targetHandle)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeDeleteBlockReason(
|
||||||
|
node: RFNode,
|
||||||
|
): CanvasNodeDeleteBlockReason | null {
|
||||||
|
if (isOptimisticNodeId(node.id)) return "optimistic";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConnectEndClientPoint(
|
||||||
|
event: MouseEvent | TouchEvent,
|
||||||
|
): { x: number; y: number } | null {
|
||||||
|
if ("clientX" in event && typeof event.clientX === "number") {
|
||||||
|
return { x: event.clientX, y: event.clientY };
|
||||||
|
}
|
||||||
|
const t = (event as TouchEvent).changedTouches?.[0];
|
||||||
|
if (t) return { x: t.clientX, y: t.clientY };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */
|
||||||
|
export type PendingEdgeSplit = {
|
||||||
|
intersectedEdgeId: Id<"edges">;
|
||||||
|
sourceNodeId: Id<"nodes">;
|
||||||
|
targetNodeId: Id<"nodes">;
|
||||||
|
intersectedSourceHandle?: string;
|
||||||
|
intersectedTargetHandle?: string;
|
||||||
|
middleSourceHandle?: string;
|
||||||
|
middleTargetHandle?: string;
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function withResolvedCompareData(nodes: RFNode[], edges: RFEdge[]): RFNode[] {
|
||||||
|
const persistedEdges = edges.filter((edge) => edge.className !== "temp");
|
||||||
|
let hasNodeUpdates = false;
|
||||||
|
|
||||||
|
const nextNodes = nodes.map((node) => {
|
||||||
|
if (node.type !== "compare") return node;
|
||||||
|
|
||||||
|
const incoming = persistedEdges.filter((edge) => edge.target === node.id);
|
||||||
|
let leftUrl: string | undefined;
|
||||||
|
let rightUrl: string | undefined;
|
||||||
|
let leftLabel: string | undefined;
|
||||||
|
let rightLabel: string | undefined;
|
||||||
|
|
||||||
|
for (const edge of incoming) {
|
||||||
|
const source = nodes.find((candidate) => candidate.id === edge.source);
|
||||||
|
if (!source) continue;
|
||||||
|
|
||||||
|
const srcData = source.data as { url?: string; label?: string };
|
||||||
|
|
||||||
|
if (edge.targetHandle === "left") {
|
||||||
|
leftUrl = srcData.url;
|
||||||
|
leftLabel = srcData.label ?? source.type ?? "Before";
|
||||||
|
} else if (edge.targetHandle === "right") {
|
||||||
|
rightUrl = srcData.url;
|
||||||
|
rightLabel = srcData.label ?? source.type ?? "After";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = node.data as {
|
||||||
|
leftUrl?: string;
|
||||||
|
rightUrl?: string;
|
||||||
|
leftLabel?: string;
|
||||||
|
rightLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
current.leftUrl === leftUrl &&
|
||||||
|
current.rightUrl === rightUrl &&
|
||||||
|
current.leftLabel === leftLabel &&
|
||||||
|
current.rightLabel === rightLabel
|
||||||
|
) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNodeUpdates = true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: { ...node.data, leftUrl, rightUrl, leftLabel, rightLabel },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasNodeUpdates ? nextNodes : nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMiniMapNodeColor(node: RFNode): string {
|
||||||
|
return node.type === "frame" ? "transparent" : "#6366f1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMiniMapNodeStrokeColor(node: RFNode): string {
|
||||||
|
return node.type === "frame" ? "transparent" : "#4f46e5";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_EDGE_OPTIONS: DefaultEdgeOptions = {
|
||||||
|
interactionWidth: 75,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EDGE_INTERSECTION_HIGHLIGHT_STYLE: NonNullable<RFEdge["style"]> = {
|
||||||
|
stroke: "var(--xy-edge-stroke)",
|
||||||
|
strokeWidth: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GENERATION_FAILURE_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
export const GENERATION_FAILURE_THRESHOLD = 3;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEditableKeyboardTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
const tag = target.tagName;
|
||||||
|
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||||
|
return target.closest("input, textarea, select, [contenteditable=true]") !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEdgeCuttable(edge: RFEdge): boolean {
|
||||||
|
if (edge.className === "temp") return false;
|
||||||
|
if (isOptimisticEdgeId(edge.id)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abstand in px zwischen Abtastpunkten beim Durchschneiden (kleiner = zuverlässiger bei schnellen Bewegungen). */
|
||||||
|
const SCISSORS_SEGMENT_SAMPLE_STEP_PX = 4;
|
||||||
|
|
||||||
|
function addCuttableEdgeIdAtClientPoint(
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
edgesList: RFEdge[],
|
||||||
|
strokeIds: Set<string>,
|
||||||
|
): void {
|
||||||
|
const id = getIntersectedEdgeId({ x: clientX, y: clientY });
|
||||||
|
if (!id) return;
|
||||||
|
const found = edgesList.find((e) => e.id === id);
|
||||||
|
if (found && isEdgeCuttable(found)) strokeIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Kanten erfassen, deren Hit-Zone die Strecke von (x0,y0) nach (x1,y1) schneidet. */
|
||||||
|
export function collectCuttableEdgesAlongScreenSegment(
|
||||||
|
x0: number,
|
||||||
|
y0: number,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
edgesList: RFEdge[],
|
||||||
|
strokeIds: Set<string>,
|
||||||
|
): void {
|
||||||
|
const dx = x1 - x0;
|
||||||
|
const dy = y1 - y0;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
if (dist < 0.5) {
|
||||||
|
addCuttableEdgeIdAtClientPoint(x1, y1, edgesList, strokeIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const steps = Math.max(1, Math.ceil(dist / SCISSORS_SEGMENT_SAMPLE_STEP_PX));
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = i / steps;
|
||||||
|
addCuttableEdgeIdAtClientPoint(
|
||||||
|
x0 + dx * t,
|
||||||
|
y0 + dy * t,
|
||||||
|
edgesList,
|
||||||
|
strokeIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasHandleKey(
|
||||||
|
handles: { source?: string; target?: string } | undefined,
|
||||||
|
key: "source" | "target",
|
||||||
|
): boolean {
|
||||||
|
if (!handles) return false;
|
||||||
|
return Object.prototype.hasOwnProperty.call(handles, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHandle(handle: string | null | undefined): string | undefined {
|
||||||
|
return handle ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shallowEqualRecord(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
|
||||||
|
const aKeys = Object.keys(a);
|
||||||
|
const bKeys = Object.keys(b);
|
||||||
|
|
||||||
|
if (aKeys.length !== bKeys.length) return false;
|
||||||
|
|
||||||
|
for (const key of aKeys) {
|
||||||
|
if (a[key] !== b[key]) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Solange der Server noch die Erstellposition liefert, lokale Zielposition nach Pending-Move halten. */
|
||||||
|
const POSITION_PIN_EPS = 0.5;
|
||||||
|
|
||||||
|
export function positionsMatchPin(a: { x: number; y: number }, b: { x: number; y: number }): boolean {
|
||||||
|
return Math.abs(a.x - b.x) <= POSITION_PIN_EPS && Math.abs(a.y - b.y) <= POSITION_PIN_EPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPinnedNodePositions(
|
||||||
|
nodes: RFNode[],
|
||||||
|
pinned: Map<string, { x: number; y: number }>,
|
||||||
|
): RFNode[] {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
const pin = pinned.get(node.id);
|
||||||
|
if (!pin) return node;
|
||||||
|
if (positionsMatchPin(node.position, pin)) {
|
||||||
|
pinned.delete(node.id);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return { ...node, position: { x: pin.x, y: pin.y } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPinnedNodePositionsReadOnly(
|
||||||
|
nodes: RFNode[],
|
||||||
|
pinned: ReadonlyMap<string, { x: number; y: number }>,
|
||||||
|
): RFNode[] {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
const pin = pinned.get(node.id);
|
||||||
|
if (!pin) return node;
|
||||||
|
if (positionsMatchPin(node.position, pin)) return node;
|
||||||
|
return { ...node, position: { x: pin.x, y: pin.y } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferPendingConnectionNodeHandoff(
|
||||||
|
previousNodes: RFNode[],
|
||||||
|
incomingConvexNodes: Doc<"nodes">[],
|
||||||
|
pendingConnectionCreates: ReadonlySet<string>,
|
||||||
|
resolvedRealIdByClientRequest: Map<string, Id<"nodes">>,
|
||||||
|
): void {
|
||||||
|
const unresolvedClientRequestIds: string[] = [];
|
||||||
|
for (const clientRequestId of pendingConnectionCreates) {
|
||||||
|
if (resolvedRealIdByClientRequest.has(clientRequestId)) continue;
|
||||||
|
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`;
|
||||||
|
const optimisticNodePresent = previousNodes.some(
|
||||||
|
(node) => node.id === optimisticNodeId,
|
||||||
|
);
|
||||||
|
if (optimisticNodePresent) {
|
||||||
|
unresolvedClientRequestIds.push(clientRequestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (unresolvedClientRequestIds.length !== 1) return;
|
||||||
|
|
||||||
|
const previousIds = new Set(previousNodes.map((node) => node.id));
|
||||||
|
const newlyAppearedIncomingRealNodeIds = incomingConvexNodes
|
||||||
|
.map((node) => node._id as string)
|
||||||
|
.filter((id) => !isOptimisticNodeId(id))
|
||||||
|
.filter((id) => !previousIds.has(id));
|
||||||
|
|
||||||
|
if (newlyAppearedIncomingRealNodeIds.length !== 1) return;
|
||||||
|
|
||||||
|
const inferredClientRequestId = unresolvedClientRequestIds[0]!;
|
||||||
|
const inferredRealId = newlyAppearedIncomingRealNodeIds[0] as Id<"nodes">;
|
||||||
|
resolvedRealIdByClientRequest.set(inferredClientRequestId, inferredRealId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMoveNodeOpPayload(
|
||||||
|
payload: unknown,
|
||||||
|
): payload is { nodeId: Id<"nodes">; positionX: number; positionY: number } {
|
||||||
|
if (typeof payload !== "object" || payload === null) return false;
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof record.nodeId === "string" &&
|
||||||
|
typeof record.positionX === "number" &&
|
||||||
|
typeof record.positionY === "number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBatchMoveNodesOpPayload(
|
||||||
|
payload: unknown,
|
||||||
|
): payload is {
|
||||||
|
moves: { nodeId: Id<"nodes">; positionX: number; positionY: number }[];
|
||||||
|
} {
|
||||||
|
if (typeof payload !== "object" || payload === null) return false;
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
if (!Array.isArray(record.moves)) return false;
|
||||||
|
return record.moves.every(isMoveNodeOpPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingMovePinsFromLocalOps(
|
||||||
|
canvasId: string,
|
||||||
|
): Map<string, { x: number; y: number }> {
|
||||||
|
const pins = new Map<string, { x: number; y: number }>();
|
||||||
|
for (const op of readCanvasOps(canvasId)) {
|
||||||
|
if (op.type === "moveNode" && isMoveNodeOpPayload(op.payload)) {
|
||||||
|
pins.set(op.payload.nodeId as string, {
|
||||||
|
x: op.payload.positionX,
|
||||||
|
y: op.payload.positionY,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (op.type === "batchMoveNodes" && isBatchMoveNodesOpPayload(op.payload)) {
|
||||||
|
for (const move of op.payload.moves) {
|
||||||
|
pins.set(move.nodeId as string, {
|
||||||
|
x: move.positionX,
|
||||||
|
y: move.positionY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pins;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeNodesPreservingLocalState(
|
||||||
|
previousNodes: RFNode[],
|
||||||
|
incomingNodes: RFNode[],
|
||||||
|
realIdByClientRequest?: ReadonlyMap<string, Id<"nodes">>,
|
||||||
|
/** Nach `onNodesChange` (position) bis `onNodeDragStop`: lokalen Stand gegen veralteten Convex-Snapshot bevorzugen. */
|
||||||
|
preferLocalPositionForNodeIds?: ReadonlySet<string>,
|
||||||
|
): RFNode[] {
|
||||||
|
const previousById = new Map(previousNodes.map((node) => [node.id, node]));
|
||||||
|
|
||||||
|
const optimisticPredecessorByRealId = new Map<string, RFNode>();
|
||||||
|
if (realIdByClientRequest && realIdByClientRequest.size > 0) {
|
||||||
|
for (const [clientRequestId, realId] of realIdByClientRequest) {
|
||||||
|
const optId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`;
|
||||||
|
const pred = previousById.get(optId);
|
||||||
|
if (pred) {
|
||||||
|
optimisticPredecessorByRealId.set(realId as string, pred);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return incomingNodes.map((incomingNode) => {
|
||||||
|
const handoffPrev = optimisticPredecessorByRealId.get(incomingNode.id);
|
||||||
|
if (handoffPrev) {
|
||||||
|
return {
|
||||||
|
...incomingNode,
|
||||||
|
position: handoffPrev.position,
|
||||||
|
selected: handoffPrev.selected,
|
||||||
|
dragging: handoffPrev.dragging,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousNode = previousById.get(incomingNode.id);
|
||||||
|
if (!previousNode) {
|
||||||
|
return incomingNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousData = previousNode.data as Record<string, unknown>;
|
||||||
|
const incomingData = incomingNode.data as Record<string, unknown>;
|
||||||
|
const previousWidth = previousNode.style?.width;
|
||||||
|
const previousHeight = previousNode.style?.height;
|
||||||
|
const incomingWidth = incomingNode.style?.width;
|
||||||
|
const incomingHeight = incomingNode.style?.height;
|
||||||
|
|
||||||
|
const isStructurallyEqual =
|
||||||
|
previousNode.type === incomingNode.type &&
|
||||||
|
previousNode.parentId === incomingNode.parentId &&
|
||||||
|
previousNode.zIndex === incomingNode.zIndex &&
|
||||||
|
previousNode.position.x === incomingNode.position.x &&
|
||||||
|
previousNode.position.y === incomingNode.position.y &&
|
||||||
|
previousWidth === incomingWidth &&
|
||||||
|
previousHeight === incomingHeight &&
|
||||||
|
shallowEqualRecord(previousData, incomingData);
|
||||||
|
|
||||||
|
if (isStructurallyEqual) {
|
||||||
|
return previousNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incomingNode.type === "prompt") {
|
||||||
|
const prevW =
|
||||||
|
typeof previousNode.style?.width === "number" ? previousNode.style.width : null;
|
||||||
|
const prevH =
|
||||||
|
typeof previousNode.style?.height === "number" ? previousNode.style.height : null;
|
||||||
|
const inW =
|
||||||
|
typeof incomingNode.style?.width === "number" ? incomingNode.style.width : null;
|
||||||
|
const inH =
|
||||||
|
typeof incomingNode.style?.height === "number" ? incomingNode.style.height : null;
|
||||||
|
void prevW;
|
||||||
|
void prevH;
|
||||||
|
void inW;
|
||||||
|
void inH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousResizing =
|
||||||
|
typeof (previousNode as { resizing?: boolean }).resizing === "boolean"
|
||||||
|
? (previousNode as { resizing?: boolean }).resizing
|
||||||
|
: false;
|
||||||
|
const preferLocalPosition =
|
||||||
|
Boolean(previousNode.dragging) ||
|
||||||
|
(preferLocalPositionForNodeIds?.has(incomingNode.id) ?? false);
|
||||||
|
const isMediaNode =
|
||||||
|
incomingNode.type === "asset" ||
|
||||||
|
incomingNode.type === "image" ||
|
||||||
|
incomingNode.type === "ai-image";
|
||||||
|
const shouldPreserveInteractivePosition =
|
||||||
|
isMediaNode &&
|
||||||
|
(Boolean(previousNode.selected) ||
|
||||||
|
Boolean(previousNode.dragging) ||
|
||||||
|
previousResizing);
|
||||||
|
const shouldPreserveInteractiveSize =
|
||||||
|
isMediaNode && (Boolean(previousNode.dragging) || previousResizing);
|
||||||
|
|
||||||
|
const previousStyleWidth =
|
||||||
|
typeof previousNode.style?.width === "number" ? previousNode.style.width : null;
|
||||||
|
const previousStyleHeight =
|
||||||
|
typeof previousNode.style?.height === "number" ? previousNode.style.height : null;
|
||||||
|
const incomingStyleWidth =
|
||||||
|
typeof incomingNode.style?.width === "number" ? incomingNode.style.width : null;
|
||||||
|
const incomingStyleHeight =
|
||||||
|
typeof incomingNode.style?.height === "number" ? incomingNode.style.height : null;
|
||||||
|
const isAssetSeedSize = previousStyleWidth === 260 && previousStyleHeight === 240;
|
||||||
|
const isImageSeedSize = previousStyleWidth === 280 && previousStyleHeight === 200;
|
||||||
|
const canApplySeedSizeCorrection =
|
||||||
|
isMediaNode &&
|
||||||
|
Boolean(previousNode.selected) &&
|
||||||
|
!previousNode.dragging &&
|
||||||
|
!previousResizing &&
|
||||||
|
((incomingNode.type === "asset" && isAssetSeedSize) ||
|
||||||
|
(incomingNode.type === "image" && isImageSeedSize)) &&
|
||||||
|
incomingStyleWidth !== null &&
|
||||||
|
incomingStyleHeight !== null &&
|
||||||
|
(incomingStyleWidth !== previousStyleWidth ||
|
||||||
|
incomingStyleHeight !== previousStyleHeight);
|
||||||
|
|
||||||
|
if (shouldPreserveInteractivePosition) {
|
||||||
|
const nextStyle =
|
||||||
|
shouldPreserveInteractiveSize || !canApplySeedSizeCorrection
|
||||||
|
? previousNode.style
|
||||||
|
: incomingNode.style;
|
||||||
|
return {
|
||||||
|
...previousNode,
|
||||||
|
...incomingNode,
|
||||||
|
position: previousNode.position,
|
||||||
|
style: nextStyle,
|
||||||
|
selected: previousNode.selected,
|
||||||
|
dragging: previousNode.dragging,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previousNode,
|
||||||
|
...incomingNode,
|
||||||
|
position: preferLocalPosition ? previousNode.position : incomingNode.position,
|
||||||
|
selected: previousNode.selected,
|
||||||
|
dragging: previousNode.dragging,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
28
components/canvas/canvas-media-utils.ts
Normal file
28
components/canvas/canvas-media-utils.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export async function getImageDimensions(
|
||||||
|
file: File,
|
||||||
|
): Promise<{ width: number; height: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
const image = new window.Image();
|
||||||
|
|
||||||
|
image.onload = () => {
|
||||||
|
const width = image.naturalWidth;
|
||||||
|
const height = image.naturalHeight;
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
|
||||||
|
if (!width || !height) {
|
||||||
|
reject(new Error("Could not read image dimensions"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ width, height });
|
||||||
|
};
|
||||||
|
|
||||||
|
image.onerror = () => {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
reject(new Error("Could not decode image"));
|
||||||
|
};
|
||||||
|
|
||||||
|
image.src = objectUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
223
components/canvas/canvas-node-change-helpers.ts
Normal file
223
components/canvas/canvas-node-change-helpers.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import type { Node as RFNode, NodeChange } from "@xyflow/react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AI_IMAGE_NODE_FOOTER_PX,
|
||||||
|
AI_IMAGE_NODE_HEADER_PX,
|
||||||
|
DEFAULT_ASPECT_RATIO,
|
||||||
|
parseAspectRatioString,
|
||||||
|
} from "@/lib/image-formats";
|
||||||
|
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
||||||
|
|
||||||
|
function isActiveResizeChange(change: NodeChange): boolean {
|
||||||
|
return change.type === "dimensions" &&
|
||||||
|
Boolean(change.dimensions) &&
|
||||||
|
(change.resizing === true || change.resizing === false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustAssetNodeDimensionsChange(
|
||||||
|
change: NodeChange,
|
||||||
|
node: RFNode,
|
||||||
|
allChanges: NodeChange[],
|
||||||
|
): NodeChange | null {
|
||||||
|
if (change.type !== "dimensions" || !change.dimensions) return change;
|
||||||
|
|
||||||
|
const isActiveResize = isActiveResizeChange(change);
|
||||||
|
const nodeResizing = Boolean((node as { resizing?: boolean }).resizing);
|
||||||
|
const hasResizingTrueInBatch = allChanges.some(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.type === "dimensions" &&
|
||||||
|
"id" in candidate &&
|
||||||
|
candidate.id === change.id &&
|
||||||
|
candidate.resizing === true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isActiveResize && (nodeResizing || hasResizingTrueInBatch)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isActiveResize) {
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeData = node.data as {
|
||||||
|
intrinsicWidth?: number;
|
||||||
|
intrinsicHeight?: number;
|
||||||
|
orientation?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasIntrinsicRatioInput =
|
||||||
|
typeof nodeData.intrinsicWidth === "number" &&
|
||||||
|
nodeData.intrinsicWidth > 0 &&
|
||||||
|
typeof nodeData.intrinsicHeight === "number" &&
|
||||||
|
nodeData.intrinsicHeight > 0;
|
||||||
|
if (!hasIntrinsicRatioInput) {
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRatio = resolveMediaAspectRatio(
|
||||||
|
nodeData.intrinsicWidth,
|
||||||
|
nodeData.intrinsicHeight,
|
||||||
|
nodeData.orientation,
|
||||||
|
);
|
||||||
|
if (!Number.isFinite(targetRatio) || targetRatio <= 0) {
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousWidth =
|
||||||
|
typeof node.style?.width === "number" ? node.style.width : change.dimensions.width;
|
||||||
|
const previousHeight =
|
||||||
|
typeof node.style?.height === "number" ? node.style.height : change.dimensions.height;
|
||||||
|
|
||||||
|
const widthDelta = Math.abs(change.dimensions.width - previousWidth);
|
||||||
|
const heightDelta = Math.abs(change.dimensions.height - previousHeight);
|
||||||
|
|
||||||
|
let constrainedWidth = change.dimensions.width;
|
||||||
|
let constrainedHeight = change.dimensions.height;
|
||||||
|
|
||||||
|
const assetChromeHeight = 88;
|
||||||
|
const assetMinPreviewHeight = 150;
|
||||||
|
const assetMinNodeHeight = assetChromeHeight + assetMinPreviewHeight;
|
||||||
|
const assetMinNodeWidth = 200;
|
||||||
|
|
||||||
|
if (heightDelta > widthDelta) {
|
||||||
|
const previewHeight = Math.max(1, constrainedHeight - assetChromeHeight);
|
||||||
|
constrainedWidth = previewHeight * targetRatio;
|
||||||
|
constrainedHeight = assetChromeHeight + previewHeight;
|
||||||
|
} else {
|
||||||
|
const previewHeight = constrainedWidth / targetRatio;
|
||||||
|
constrainedHeight = assetChromeHeight + previewHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minWidthFromPreview = assetMinPreviewHeight * targetRatio;
|
||||||
|
const minimumAllowedWidth = Math.max(assetMinNodeWidth, minWidthFromPreview);
|
||||||
|
const minPreviewFromWidth = minimumAllowedWidth / targetRatio;
|
||||||
|
const minimumAllowedHeight = Math.max(
|
||||||
|
assetMinNodeHeight,
|
||||||
|
assetChromeHeight + minPreviewFromWidth,
|
||||||
|
);
|
||||||
|
|
||||||
|
let enforcedWidth = Math.max(constrainedWidth, minimumAllowedWidth);
|
||||||
|
let enforcedHeight = assetChromeHeight + enforcedWidth / targetRatio;
|
||||||
|
if (enforcedHeight < minimumAllowedHeight) {
|
||||||
|
enforcedHeight = minimumAllowedHeight;
|
||||||
|
enforcedWidth = (enforcedHeight - assetChromeHeight) * targetRatio;
|
||||||
|
}
|
||||||
|
enforcedWidth = Math.max(enforcedWidth, minimumAllowedWidth);
|
||||||
|
enforcedHeight = assetChromeHeight + enforcedWidth / targetRatio;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...change,
|
||||||
|
dimensions: {
|
||||||
|
...change.dimensions,
|
||||||
|
width: enforcedWidth,
|
||||||
|
height: enforcedHeight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustAiImageNodeDimensionsChange(
|
||||||
|
change: NodeChange,
|
||||||
|
node: RFNode,
|
||||||
|
): NodeChange {
|
||||||
|
if (change.type !== "dimensions" || !change.dimensions) return change;
|
||||||
|
|
||||||
|
const isActiveResize = isActiveResizeChange(change);
|
||||||
|
if (!isActiveResize) {
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeData = node.data as { aspectRatio?: string };
|
||||||
|
const arLabel =
|
||||||
|
typeof nodeData.aspectRatio === "string" && nodeData.aspectRatio.trim()
|
||||||
|
? nodeData.aspectRatio.trim()
|
||||||
|
: DEFAULT_ASPECT_RATIO;
|
||||||
|
|
||||||
|
let arW: number;
|
||||||
|
let arH: number;
|
||||||
|
try {
|
||||||
|
const parsed = parseAspectRatioString(arLabel);
|
||||||
|
arW = parsed.w;
|
||||||
|
arH = parsed.h;
|
||||||
|
} catch {
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chrome = AI_IMAGE_NODE_HEADER_PX + AI_IMAGE_NODE_FOOTER_PX;
|
||||||
|
const hPerW = arH / arW;
|
||||||
|
|
||||||
|
const previousWidth =
|
||||||
|
typeof node.style?.width === "number" ? node.style.width : change.dimensions.width;
|
||||||
|
const previousHeight =
|
||||||
|
typeof node.style?.height === "number" ? node.style.height : change.dimensions.height;
|
||||||
|
|
||||||
|
const widthDelta = Math.abs(change.dimensions.width - previousWidth);
|
||||||
|
const heightDelta = Math.abs(change.dimensions.height - previousHeight);
|
||||||
|
|
||||||
|
let constrainedWidth = change.dimensions.width;
|
||||||
|
let constrainedHeight = change.dimensions.height;
|
||||||
|
|
||||||
|
if (heightDelta > widthDelta) {
|
||||||
|
const viewportHeight = Math.max(1, constrainedHeight - chrome);
|
||||||
|
constrainedWidth = viewportHeight * (arW / arH);
|
||||||
|
constrainedHeight = chrome + viewportHeight;
|
||||||
|
} else {
|
||||||
|
constrainedHeight = chrome + constrainedWidth * hPerW;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiMinViewport = 120;
|
||||||
|
const aiMinOuterHeight = chrome + aiMinViewport;
|
||||||
|
const aiMinOuterWidthBase = 200;
|
||||||
|
const minimumAllowedWidth = Math.max(
|
||||||
|
aiMinOuterWidthBase,
|
||||||
|
aiMinViewport * (arW / arH),
|
||||||
|
);
|
||||||
|
const minimumAllowedHeight = Math.max(
|
||||||
|
aiMinOuterHeight,
|
||||||
|
chrome + minimumAllowedWidth * hPerW,
|
||||||
|
);
|
||||||
|
|
||||||
|
let enforcedWidth = Math.max(constrainedWidth, minimumAllowedWidth);
|
||||||
|
let enforcedHeight = chrome + enforcedWidth * hPerW;
|
||||||
|
if (enforcedHeight < minimumAllowedHeight) {
|
||||||
|
enforcedHeight = minimumAllowedHeight;
|
||||||
|
enforcedWidth = (enforcedHeight - chrome) * (arW / arH);
|
||||||
|
}
|
||||||
|
enforcedWidth = Math.max(enforcedWidth, minimumAllowedWidth);
|
||||||
|
enforcedHeight = chrome + enforcedWidth * hPerW;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...change,
|
||||||
|
dimensions: {
|
||||||
|
...change.dimensions,
|
||||||
|
width: enforcedWidth,
|
||||||
|
height: enforcedHeight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adjustNodeDimensionChanges(
|
||||||
|
changes: NodeChange[],
|
||||||
|
nodes: RFNode[],
|
||||||
|
): NodeChange[] {
|
||||||
|
return changes
|
||||||
|
.map((change) => {
|
||||||
|
if (change.type !== "dimensions" || !change.dimensions) {
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = nodes.find((candidate) => candidate.id === change.id);
|
||||||
|
if (!node) {
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "asset") {
|
||||||
|
return adjustAssetNodeDimensionsChange(change, node, changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "ai-image") {
|
||||||
|
return adjustAiImageNodeDimensionsChange(change, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return change;
|
||||||
|
})
|
||||||
|
.filter((change): change is NodeChange => change !== null);
|
||||||
|
}
|
||||||
76
components/canvas/canvas-reconnect.ts
Normal file
76
components/canvas/canvas-reconnect.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||||
|
import { reconnectEdge, type Connection, type Edge as RFEdge } from "@xyflow/react";
|
||||||
|
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
|
||||||
|
import { isOptimisticEdgeId } from "./canvas-helpers";
|
||||||
|
|
||||||
|
type UseCanvasReconnectHandlersParams = {
|
||||||
|
edgeReconnectSuccessful: MutableRefObject<boolean>;
|
||||||
|
isReconnectDragActiveRef: MutableRefObject<boolean>;
|
||||||
|
setEdges: Dispatch<SetStateAction<RFEdge[]>>;
|
||||||
|
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCanvasReconnectHandlers({
|
||||||
|
edgeReconnectSuccessful,
|
||||||
|
isReconnectDragActiveRef,
|
||||||
|
setEdges,
|
||||||
|
runRemoveEdgeMutation,
|
||||||
|
}: UseCanvasReconnectHandlersParams): {
|
||||||
|
onReconnectStart: () => void;
|
||||||
|
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
|
||||||
|
onReconnectEnd: (_: MouseEvent | TouchEvent, edge: RFEdge) => void;
|
||||||
|
} {
|
||||||
|
const onReconnectStart = useCallback(() => {
|
||||||
|
edgeReconnectSuccessful.current = false;
|
||||||
|
isReconnectDragActiveRef.current = true;
|
||||||
|
}, [edgeReconnectSuccessful, isReconnectDragActiveRef]);
|
||||||
|
|
||||||
|
const onReconnect = useCallback(
|
||||||
|
(oldEdge: RFEdge, newConnection: Connection) => {
|
||||||
|
edgeReconnectSuccessful.current = true;
|
||||||
|
setEdges((currentEdges) => reconnectEdge(oldEdge, newConnection, currentEdges));
|
||||||
|
},
|
||||||
|
[edgeReconnectSuccessful, setEdges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onReconnectEnd = useCallback(
|
||||||
|
(_: MouseEvent | TouchEvent, edge: RFEdge) => {
|
||||||
|
try {
|
||||||
|
if (!edgeReconnectSuccessful.current) {
|
||||||
|
setEdges((currentEdges) =>
|
||||||
|
currentEdges.filter((candidate) => candidate.id !== edge.id),
|
||||||
|
);
|
||||||
|
if (edge.className === "temp") {
|
||||||
|
edgeReconnectSuccessful.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOptimisticEdgeId(edge.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch(
|
||||||
|
(error) => {
|
||||||
|
console.error("[Canvas edge remove failed] reconnect end", {
|
||||||
|
edgeId: edge.id,
|
||||||
|
edgeClassName: edge.className ?? null,
|
||||||
|
source: edge.source,
|
||||||
|
target: edge.target,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
edgeReconnectSuccessful.current = true;
|
||||||
|
} finally {
|
||||||
|
isReconnectDragActiveRef.current = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[edgeReconnectSuccessful, isReconnectDragActiveRef, runRemoveEdgeMutation, setEdges],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { onReconnectStart, onReconnect, onReconnectEnd };
|
||||||
|
}
|
||||||
159
components/canvas/canvas-scissors.ts
Normal file
159
components/canvas/canvas-scissors.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import type {
|
||||||
|
Dispatch,
|
||||||
|
MutableRefObject,
|
||||||
|
SetStateAction,
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
PointerEvent as ReactPointerEvent,
|
||||||
|
} from "react";
|
||||||
|
import type { Edge as RFEdge } from "@xyflow/react";
|
||||||
|
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
import type { CanvasNavTool } from "@/components/canvas/canvas-toolbar";
|
||||||
|
import {
|
||||||
|
collectCuttableEdgesAlongScreenSegment,
|
||||||
|
getIntersectedEdgeId,
|
||||||
|
isEdgeCuttable,
|
||||||
|
isEditableKeyboardTarget,
|
||||||
|
} from "./canvas-helpers";
|
||||||
|
|
||||||
|
type Point = { x: number; y: number };
|
||||||
|
|
||||||
|
type UseCanvasScissorsParams = {
|
||||||
|
scissorsMode: boolean;
|
||||||
|
scissorsModeRef: MutableRefObject<boolean>;
|
||||||
|
edgesRef: MutableRefObject<RFEdge[]>;
|
||||||
|
setScissorsMode: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setNavTool: Dispatch<SetStateAction<CanvasNavTool>>;
|
||||||
|
setScissorStrokePreview: Dispatch<SetStateAction<Point[] | null>>;
|
||||||
|
runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCanvasScissors({
|
||||||
|
scissorsMode,
|
||||||
|
scissorsModeRef,
|
||||||
|
edgesRef,
|
||||||
|
setScissorsMode,
|
||||||
|
setNavTool,
|
||||||
|
setScissorStrokePreview,
|
||||||
|
runRemoveEdgeMutation,
|
||||||
|
}: UseCanvasScissorsParams): {
|
||||||
|
onEdgeClickScissors: (_event: ReactMouseEvent, edge: RFEdge) => void;
|
||||||
|
onScissorsFlowPointerDownCapture: (event: ReactPointerEvent) => void;
|
||||||
|
} {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape" && scissorsModeRef.current) {
|
||||||
|
setScissorsMode(false);
|
||||||
|
setNavTool("select");
|
||||||
|
setScissorStrokePreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
|
const isScissorHotkey =
|
||||||
|
event.key.length === 1 && event.key.toLowerCase() === "k";
|
||||||
|
if (!isScissorHotkey) return;
|
||||||
|
if (isEditableKeyboardTarget(event.target)) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
if (scissorsModeRef.current) {
|
||||||
|
setScissorsMode(false);
|
||||||
|
setNavTool("select");
|
||||||
|
} else {
|
||||||
|
setScissorsMode(true);
|
||||||
|
setNavTool("scissor");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [scissorsModeRef, setNavTool, setScissorStrokePreview, setScissorsMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scissorsMode) {
|
||||||
|
setScissorStrokePreview(null);
|
||||||
|
}
|
||||||
|
}, [scissorsMode, setScissorStrokePreview]);
|
||||||
|
|
||||||
|
const onEdgeClickScissors = useCallback(
|
||||||
|
(_event: ReactMouseEvent, edge: RFEdge) => {
|
||||||
|
if (!scissorsModeRef.current) return;
|
||||||
|
if (!isEdgeCuttable(edge)) return;
|
||||||
|
|
||||||
|
void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch(
|
||||||
|
(error) => {
|
||||||
|
console.error("[Canvas] scissors edge click remove failed", {
|
||||||
|
edgeId: edge.id,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[runRemoveEdgeMutation, scissorsModeRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onScissorsFlowPointerDownCapture = useCallback(
|
||||||
|
(event: ReactPointerEvent) => {
|
||||||
|
if (!scissorsModeRef.current) return;
|
||||||
|
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||||
|
|
||||||
|
const targetElement = event.target as HTMLElement;
|
||||||
|
if (targetElement.closest(".react-flow__node")) return;
|
||||||
|
if (targetElement.closest(".react-flow__controls")) return;
|
||||||
|
if (targetElement.closest(".react-flow__minimap")) return;
|
||||||
|
if (!targetElement.closest(".react-flow__pane")) return;
|
||||||
|
if (getIntersectedEdgeId({ x: event.clientX, y: event.clientY })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const strokeIds = new Set<string>();
|
||||||
|
const points: Point[] = [{ x: event.clientX, y: event.clientY }];
|
||||||
|
setScissorStrokePreview(points);
|
||||||
|
|
||||||
|
const handleMove = (pointerEvent: PointerEvent) => {
|
||||||
|
const previous = points[points.length - 1]!;
|
||||||
|
const nextX = pointerEvent.clientX;
|
||||||
|
const nextY = pointerEvent.clientY;
|
||||||
|
|
||||||
|
collectCuttableEdgesAlongScreenSegment(
|
||||||
|
previous.x,
|
||||||
|
previous.y,
|
||||||
|
nextX,
|
||||||
|
nextY,
|
||||||
|
edgesRef.current,
|
||||||
|
strokeIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
points.push({ x: nextX, y: nextY });
|
||||||
|
setScissorStrokePreview([...points]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUp = () => {
|
||||||
|
window.removeEventListener("pointermove", handleMove);
|
||||||
|
window.removeEventListener("pointerup", handleUp);
|
||||||
|
window.removeEventListener("pointercancel", handleUp);
|
||||||
|
setScissorStrokePreview(null);
|
||||||
|
if (!scissorsModeRef.current) return;
|
||||||
|
|
||||||
|
for (const id of strokeIds) {
|
||||||
|
void runRemoveEdgeMutation({ edgeId: id as Id<"edges"> }).catch(
|
||||||
|
(error) => {
|
||||||
|
console.error("[Canvas] scissors stroke remove failed", {
|
||||||
|
edgeId: id,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", handleMove);
|
||||||
|
window.addEventListener("pointerup", handleUp);
|
||||||
|
window.addEventListener("pointercancel", handleUp);
|
||||||
|
event.preventDefault();
|
||||||
|
},
|
||||||
|
[edgesRef, runRemoveEdgeMutation, scissorsModeRef, setScissorStrokePreview],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { onEdgeClickScissors, onScissorsFlowPointerDownCapture };
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user