diff --git a/app/globals.css b/app/globals.css index 0cb9fe4..daedb57 100644 --- a/app/globals.css +++ b/app/globals.css @@ -202,4 +202,13 @@ .react-flow.dark .react-flow__edge.temp .react-flow__edge-path { stroke: rgba(189, 195, 199, 0.35); } + + /* Scherenmodus: Scheren-Cursor (Teal, Fallback crosshair) */ + .react-flow.canvas-scissors-mode .react-flow__pane, + .react-flow.canvas-scissors-mode .react-flow__edge-interaction { + cursor: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%230d9488' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='6' cy='6' r='3'/%3E%3Ccircle cx='6' cy='18' r='3'/%3E%3Cpath d='M20 4 8.12 15.88M14.47 14.48 20 20M8.12 8.12 12 12'/%3E%3C/svg%3E") + 12 12, + crosshair; + } } diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 5f27062..21cb8b8 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -1,6 +1,13 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; import { useTheme } from "next-themes"; import { ReactFlow, @@ -21,6 +28,7 @@ import { type OnConnectEnd, BackgroundVariant, } from "@xyflow/react"; +import { cn } from "@/lib/utils"; import "@xyflow/react/dist/style.css"; import { toast } from "@/lib/toast"; import { msg } from "@/lib/toast-messages"; @@ -218,6 +226,20 @@ function getIntersectedEdgeId(point: { x: number; y: number }): string | null { return getEdgeIdFromInteractionElement(interactionElement); } +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; +} + +function isEdgeCuttable(edge: RFEdge): boolean { + if (edge.className === "temp") return false; + if (isOptimisticEdgeId(edge.id)) return false; + return true; +} + function hasHandleKey( handles: { source?: string; target?: string } | undefined, key: "source" | "target", @@ -794,6 +816,15 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const connectionDropMenuRef = useRef(null); connectionDropMenuRef.current = connectionDropMenu; + const [scissorsMode, setScissorsMode] = useState(false); + const [scissorStrokePreview, setScissorStrokePreview] = useState< + { x: number; y: number }[] | null + >(null); + const edgesRef = useRef(edges); + edgesRef.current = edges; + const scissorsModeRef = useRef(scissorsMode); + scissorsModeRef.current = scissorsMode; + // Drag-Lock: während des Drags kein Convex-Override const isDragging = useRef(false); @@ -1707,6 +1738,99 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { [screenToFlowPosition, createNode, canvasId, syncPendingMoveForClientRequest], ); + // ─── Scherenmodus (K) — Kante klicken oder mit Maus durchschneiden ─ + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && scissorsModeRef.current) { + setScissorsMode(false); + setScissorStrokePreview(null); + return; + } + if (e.metaKey || e.ctrlKey || e.altKey) return; + const k = e.key.length === 1 && e.key.toLowerCase() === "k"; + if (!k) return; + if (isEditableKeyboardTarget(e.target)) return; + e.preventDefault(); + setScissorsMode((prev) => !prev); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, []); + + useEffect(() => { + if (!scissorsMode) { + setScissorStrokePreview(null); + } + }, [scissorsMode]); + + const onEdgeClickScissors = useCallback( + (_event: ReactMouseEvent, edge: RFEdge) => { + if (!scissorsModeRef.current) return; + if (!isEdgeCuttable(edge)) return; + void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => { + console.error("[Canvas] scissors edge click remove failed", { + edgeId: edge.id, + error: String(error), + }); + }); + }, + [removeEdge], + ); + + const onScissorsFlowPointerDownCapture = useCallback( + (event: ReactPointerEvent) => { + if (!scissorsModeRef.current) return; + if (event.pointerType === "mouse" && event.button !== 0) return; + + const el = event.target as HTMLElement; + if (el.closest(".react-flow__node")) return; + if (el.closest(".react-flow__controls")) return; + if (el.closest(".react-flow__minimap")) return; + if (!el.closest(".react-flow__pane")) return; + if (getIntersectedEdgeId({ x: event.clientX, y: event.clientY })) { + return; + } + + const strokeIds = new Set(); + const points: { x: number; y: number }[] = [ + { x: event.clientX, y: event.clientY }, + ]; + setScissorStrokePreview(points); + + const handleMove = (ev: PointerEvent) => { + points.push({ x: ev.clientX, y: ev.clientY }); + setScissorStrokePreview([...points]); + const id = getIntersectedEdgeId({ x: ev.clientX, y: ev.clientY }); + if (id) { + const found = edgesRef.current.find((ed) => ed.id === id); + if (found && isEdgeCuttable(found)) strokeIds.add(id); + } + }; + + 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 removeEdge({ 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(); + }, + [removeEdge], + ); + // ─── Loading State ──────────────────────────────────────────── if (convexNodes === undefined || convexEdges === undefined) { return ( @@ -1738,6 +1862,36 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { onClose={() => setConnectionDropMenu(null)} onPick={handleConnectionDropPick} /> + {scissorsMode ? ( +
+ Scherenmodus — Kante anklicken oder ziehen zum Durchtrennen ·{" "} + Esc oder K beenden · Mitte/Rechtsklick zum + Verschieben +
+ ) : null} + {scissorStrokePreview && scissorStrokePreview.length > 1 ? ( + + `${p.x},${p.y}`) + .join(" ")} + /> + + ) : null} +
@@ -1778,6 +1935,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { maskColor="rgba(0, 0, 0, 0.1)" /> +
);