From f80ef204ed891cc54d8fc848819ee52ea7652252 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 3 Mar 2026 21:44:11 +1300 Subject: [PATCH] feat: enable drag-to-move nodes from floating header Implement custom drag handling on FloatingNodeHeader so users can reposition nodes by clicking and dragging the header bar. Supports multi-selection dragging and properly ignores interactive elements (buttons, inputs). Adds cursor-grab to indicate draggability. Co-Authored-By: Claude Opus 4.6 --- src/components/nodes/FloatingNodeHeader.tsx | 71 ++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/src/components/nodes/FloatingNodeHeader.tsx b/src/components/nodes/FloatingNodeHeader.tsx index 85f50973..49b710b8 100644 --- a/src/components/nodes/FloatingNodeHeader.tsx +++ b/src/components/nodes/FloatingNodeHeader.tsx @@ -2,6 +2,7 @@ import { ReactNode, useState, useEffect, useRef, useCallback } from "react"; import { createPortal } from "react-dom"; +import { useReactFlow } from "@xyflow/react"; import { NodeType } from "@/types"; export interface CommentNavigationProps { @@ -201,9 +202,76 @@ export function FloatingNodeHeader({ // Determine if controls should be visible const showControls = isHovered || selected; + // Drag-to-move: allow repositioning nodes by dragging the header + const { setNodes, getNodes, getViewport } = useReactFlow(); + const isDraggingRef = useRef(false); + + const handleHeaderMouseDown = useCallback((e: React.MouseEvent) => { + // Don't drag from interactive elements + if ((e.target as HTMLElement).closest('.nodrag, button, input, textarea, a')) return; + if (e.button !== 0) return; + + e.preventDefault(); + + const startX = e.clientX; + const startY = e.clientY; + + const allNodes = getNodes(); + const targetNode = allNodes.find(n => n.id === id); + if (!targetNode) return; + + // Select this node if not already selected + if (!targetNode.selected) { + setNodes(nodes => nodes.map(n => ({ + ...n, + selected: n.id === id, + }))); + } + + // Capture starting positions of all nodes that will move + const movingIds = targetNode.selected + ? new Set(allNodes.filter(n => n.selected).map(n => n.id)) + : new Set([id]); + const startPositions = new Map( + allNodes.filter(n => movingIds.has(n.id)).map(n => [n.id, { x: n.position.x, y: n.position.y }]) + ); + + isDraggingRef.current = false; + + const handleMouseMove = (e: MouseEvent) => { + const { zoom } = getViewport(); + const dx = (e.clientX - startX) / zoom; + const dy = (e.clientY - startY) / zoom; + + if (Math.abs(dx) > 2 || Math.abs(dy) > 2) { + isDraggingRef.current = true; + } + + if (isDraggingRef.current) { + setNodes(nodes => nodes.map(n => { + const startPos = startPositions.get(n.id); + if (!startPos) return n; + return { + ...n, + position: { x: startPos.x + dx, y: startPos.y + dy }, + }; + })); + } + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + isDraggingRef.current = false; + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, [id, getNodes, getViewport, setNodes]); + return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + onMouseDown={handleHeaderMouseDown} >
{/* Title Section */}