From 1b17dd5267d7309ebc90f0a368da85d4f27f3985 Mon Sep 17 00:00:00 2001 From: Reda Jamil Date: Tue, 6 Oct 2026 11:23:10 +0000 Subject: [PATCH] fix(curves): make curve endpoints easy to grab and drag - Keep the unsnapped position in the drag accumulator: writing the edge-snapped value back made points at x=0/255 unable to leave the edge with slow mouse moves (regression from the fine adjust mode). - Initialize the pointer and accumulator when dragging a parametric splitter, so it no longer jumps on the first move. - Clicking within 12px of an existing point grabs it instead of adding a new one, and no point is created on an x already used by another point. --- src/components/adjustments/Curves.tsx | 53 +++++++++++++++++++++------ 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/src/components/adjustments/Curves.tsx b/src/components/adjustments/Curves.tsx index 7122a0af19..2611f2fd0e 100644 --- a/src/components/adjustments/Curves.tsx +++ b/src/components/adjustments/Curves.tsx @@ -235,6 +235,8 @@ function convertParametricToPoints(settings: ParametricCurveSettings): Array 'shiftKey' in event && (event.shiftKey || event.altKey); @@ -413,25 +415,25 @@ export default function CurveGraph({ accumulatedPointRef.current.x = Math.max(0, Math.min(255, accumulatedPointRef.current.x + deltaX)); accumulatedPointRef.current.y = Math.max(0, Math.min(255, accumulatedPointRef.current.y + deltaY)); + const prevX = index > 0 ? currentPoints[index - 1].x : 0; + const nextX = index < currentPoints.length - 1 ? currentPoints[index + 1].x : 255; + const minX = index === 0 ? 0 : prevX + 0.01; + const maxX = index === currentPoints.length - 1 ? 255 : nextX - 0.01; + + // Keep the unsnapped position in the accumulator, otherwise small deltas near an edge + // are snapped back every move and the point can never leave it. + accumulatedPointRef.current.x = Math.max(minX, Math.min(maxX, accumulatedPointRef.current.x)); + let x = accumulatedPointRef.current.x; - let y = accumulatedPointRef.current.y; + const y = accumulatedPointRef.current.y; if (!isFineAdjust) { const SNAP_THRESHOLD = 5; if (x < SNAP_THRESHOLD) x = 0; if (x > 255 - SNAP_THRESHOLD) x = 255; + x = Math.max(minX, Math.min(maxX, x)); } - const prevX = index > 0 ? currentPoints[index - 1].x : 0; - const nextX = index < currentPoints.length - 1 ? currentPoints[index + 1].x : 255; - const minX = index === 0 ? 0 : prevX + 0.01; - const maxX = index === currentPoints.length - 1 ? 255 : nextX - 0.01; - - x = Math.max(minX, Math.min(maxX, x)); - - accumulatedPointRef.current.x = x; - accumulatedPointRef.current.y = y; - const newPoints = [...currentPoints]; newPoints[index] = { x, y }; @@ -530,16 +532,39 @@ export default function CurveGraph({ const handleContainerStart = (e: any) => { if (isParametricMode || (!e.touches && e.button !== 0) || e.target.tagName === 'circle') return; - onDragStateChange?.(true); const svg = svgRef.current; if (!svg) return; const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY; const rect = svg.getBoundingClientRect(); + + // A click that narrowly misses a point (common at the corners, where the handle overflows + // the graph) grabs that point instead of creating a new one next to it. + let nearestIndex = -1; + let nearestDistance = Infinity; + activePoints.forEach((p: Coord, i: number) => { + const px = rect.left + (p.x / 255) * rect.width; + const py = rect.top + ((255 - p.y) / 255) * rect.height; + const distance = Math.hypot(clientX - px, clientY - py); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = i; + } + }); + if (nearestIndex !== -1 && nearestDistance <= POINT_HIT_RADIUS_PX) { + handlePointStart(e, nearestIndex); + return; + } + const x = Math.max(0, Math.min(255, ((clientX - rect.left) / rect.width) * 255)); const y = Math.max(0, Math.min(255, 255 - ((clientY - rect.top) / rect.height) * 255)); + // Clicks in the padding are clamped to x = 0 or 255: never stack a second point on an existing x. + if (activePoints.some((p: Coord) => Math.abs(p.x - x) < MIN_POINT_GAP_X)) return; + + onDragStateChange?.(true); + const newPoints = [...activePoints, { x, y }].sort((a: Coord, b: Coord) => a.x - b.x); const newPointIndex = newPoints.findIndex((p: Coord) => p.x === x && p.y === y); @@ -990,12 +1015,16 @@ export default function CurveGraph({ onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); + lastPointerRef.current = { x: e.clientX, y: e.clientY }; + accumulatedSplitRef.current = activeParametricSettings[key]; localParametricSettingsRef.current = { ...activeParametricSettings }; setDraggingSplitKey(key); }} onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); + lastPointerRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }; + accumulatedSplitRef.current = activeParametricSettings[key]; localParametricSettingsRef.current = { ...activeParametricSettings }; setDraggingSplitKey(key); }}