feat: implement scissors mode for edge manipulation on canvas

- Introduced a new scissors mode that allows users to cut edges by clicking or dragging on the canvas.
- Added keyboard shortcuts for toggling scissors mode and canceling actions, enhancing user interaction.
- Implemented visual feedback for the scissors mode, including a preview of the cutting path.
- Refactored edge handling logic to support edge removal during scissors mode, improving dynamic edge management.
This commit is contained in:
Matthias
2026-03-28 17:56:27 +01:00
parent 9694c50195
commit 02f36fdc7b
2 changed files with 169 additions and 2 deletions

View File

@@ -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;
}
}

View File

@@ -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<ConnectionDropMenuState | null>(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<string>();
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 ? (
<div className="pointer-events-none absolute top-3 left-1/2 z-50 max-w-[min(100%-2rem,28rem)] -translate-x-1/2 rounded-lg bg-popover/95 px-3 py-1.5 text-center text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10">
Scherenmodus Kante anklicken oder ziehen zum Durchtrennen ·{" "}
<span className="whitespace-nowrap">Esc oder K beenden</span> · Mitte/Rechtsklick zum
Verschieben
</div>
) : null}
{scissorStrokePreview && scissorStrokePreview.length > 1 ? (
<svg
className="pointer-events-none fixed inset-0 z-60 overflow-visible"
aria-hidden
>
<polyline
fill="none"
stroke="var(--primary)"
strokeWidth={2}
strokeDasharray="6 4"
opacity={0.85}
points={scissorStrokePreview
.map((p) => `${p.x},${p.y}`)
.join(" ")}
/>
</svg>
) : null}
<div
className="relative h-full min-h-0 w-full"
onPointerDownCapture={
scissorsMode ? onScissorsFlowPointerDownCapture : undefined
}
>
<ReactFlow
nodes={nodes}
edges={edges}
@@ -1757,6 +1911,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
onReconnectEnd={onReconnectEnd}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
onEdgeClick={scissorsMode ? onEdgeClickScissors : undefined}
onError={onFlowError}
onDragOver={onDragOver}
onDrop={onDrop}
@@ -1765,9 +1920,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
snapGrid={[16, 16]}
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode="Shift"
nodesConnectable={!scissorsMode}
panOnDrag={scissorsMode ? [1, 2] : true}
proOptions={{ hideAttribution: true }}
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
className="bg-background"
className={cn("bg-background", scissorsMode && "canvas-scissors-mode")}
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls className="bg-card! border! shadow-sm! rounded-lg!" />
@@ -1779,6 +1936,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
/>
</ReactFlow>
</div>
</div>
</CanvasPlacementProvider>
);
}