fix(planner): waypoint drag no longer snaps back on release

Extract the waypoint marker into a React.memo'd component. The marker's
position/icon were new references every render, so any unrelated
PlannerMap re-render during a drag (e.g. the route-hover chart-sync
state update) re-applied the stale saved position mid-drag — orphaning
the drag so it never committed and the pin jumped back on release.
Memoizing means unrelated re-renders no longer touch the marker, so the
drag commits and the moved position persists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-07-16 08:12:06 +02:00
parent 6871b154db
commit c6ae08676b
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from "react"; import { useState, useCallback, useRef, memo, type RefObject } from "react";
import { MapContainer, TileLayer, LayersControl, Marker, Polyline, Tooltip } from "react-leaflet"; import { MapContainer, TileLayer, LayersControl, Marker, Polyline, Tooltip } from "react-leaflet";
import L from "leaflet"; import L from "leaflet";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -70,6 +70,70 @@ function dayLabelIcon(dayNumber: number, distanceKm: string): L.DivIcon {
}); });
} }
interface WaypointMarkerProps {
index: number;
lat: number;
lon: number;
overnight?: boolean;
highlighted: boolean;
note?: string;
onMove: (index: number, lat: number, lng: number) => void;
onContextMenu: (index: number, x: number, y: number) => void;
suspendRef: RefObject<boolean>;
draggingRef: RefObject<boolean>;
undoManager: YjsState["undoManager"];
}
/**
* A single waypoint marker. Memoized so unrelated PlannerMap re-renders (e.g.
* route-hover chart sync) don't re-apply `position`/`icon` mid-drag which
* otherwise yanks the pin back to its saved spot and the drag never commits.
*/
const WaypointMarker = memo(function WaypointMarker({
index, lat, lon, overnight, highlighted, note,
onMove, onContextMenu, suspendRef, draggingRef, undoManager,
}: WaypointMarkerProps) {
return (
<Marker
position={[lat, lon]}
draggable
zIndexOffset={highlighted ? Z_WAYPOINT_HIGHLIGHTED : Z_WAYPOINT}
icon={waypointIcon(index, overnight, highlighted, !!note)}
eventHandlers={{
// A click listener makes Leaflet route the click to the marker
// (otherwise it falls through to the map and adds a duplicate).
click: (e) => { e.originalEvent.stopPropagation(); },
mouseover: () => { suspendRef.current = true; },
mouseout: () => { if (!draggingRef.current) suspendRef.current = false; },
dragstart: () => {
draggingRef.current = true;
undoManager.stopCapturing();
suspendRef.current = true;
},
dragend: (e) => {
draggingRef.current = false;
suspendRef.current = false;
const { lat: dLat, lng } = (e.target as L.Marker).getLatLng();
onMove(index, dLat, lng);
},
contextmenu: (e) => {
L.DomEvent.preventDefault(e.originalEvent);
const orig = e.originalEvent as MouseEvent;
onContextMenu(index, orig.clientX, orig.clientY);
},
}}
>
{note && (
<Tooltip direction="top" offset={[0, -14]} opacity={0.95}>
<span style={{ width: "max-content", maxWidth: 280, display: "inline-block", textAlign: "center", whiteSpace: "pre-wrap", overflowWrap: "break-word" }}>
{note.length > 120 ? note.slice(0, 120) + "…" : note}
</span>
</Tooltip>
)}
</Marker>
);
});
interface PlannerMapProps { interface PlannerMapProps {
yjs: YjsState; yjs: YjsState;
sessionId: string; sessionId: string;
@ -93,6 +157,9 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition,
const [noGoDrawing, setNoGoDrawing] = useState(false); const [noGoDrawing, setNoGoDrawing] = useState(false);
const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []); const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []);
const suppressMapClickRef = useRef(false); const suppressMapClickRef = useRef(false);
const handleContextMenu = useCallback((index: number, x: number, y: number) => {
setContextMenu({ x, y, index });
}, []);
const routeInteractionSuspendedRef = useRef(false); const routeInteractionSuspendedRef = useRef(false);
const waypointDraggingRef = useRef(false); const waypointDraggingRef = useRef(false);
@ -188,48 +255,20 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition,
)} )}
{waypoints.map((wp, i) => ( {waypoints.map((wp, i) => (
<Marker <WaypointMarker
key={i} key={i}
position={[wp.lat, wp.lon]} index={i}
draggable lat={wp.lat}
zIndexOffset={highlightedWaypoint === i ? Z_WAYPOINT_HIGHLIGHTED : Z_WAYPOINT} lon={wp.lon}
icon={waypointIcon(i, wp.overnight, highlightedWaypoint === i, !!wp.note)} overnight={wp.overnight}
eventHandlers={{ highlighted={highlightedWaypoint === i}
// Consume the click on the marker. Without a click listener, note={wp.note}
// Leaflet routes the click to the map (which adds a duplicate onMove={moveWaypoint}
// waypoint on top of this one). Registering it makes Leaflet onContextMenu={handleContextMenu}
// treat the marker as the event target instead. suspendRef={routeInteractionSuspendedRef}
click: (e) => { e.originalEvent.stopPropagation(); }, draggingRef={waypointDraggingRef}
mouseover: () => { routeInteractionSuspendedRef.current = true; }, undoManager={yjs.undoManager}
mouseout: () => { />
if (!waypointDraggingRef.current) routeInteractionSuspendedRef.current = false;
},
dragstart: () => {
waypointDraggingRef.current = true;
yjs.undoManager.stopCapturing();
routeInteractionSuspendedRef.current = true;
},
dragend: (e) => {
waypointDraggingRef.current = false;
routeInteractionSuspendedRef.current = false;
const { lat, lng } = e.target.getLatLng();
moveWaypoint(i, lat, lng);
},
contextmenu: (e) => {
L.DomEvent.preventDefault(e.originalEvent);
const orig = e.originalEvent as MouseEvent;
setContextMenu({ x: orig.clientX, y: orig.clientY, index: i });
},
}}
>
{wp.note && (
<Tooltip direction="top" offset={[0, -14]} opacity={0.95}>
<span style={{ width: "max-content", maxWidth: 280, display: "inline-block", textAlign: "center", whiteSpace: "pre-wrap", overflowWrap: "break-word" }}>
{wp.note.length > 120 ? wp.note.slice(0, 120) + "…" : wp.note}
</span>
</Tooltip>
)}
</Marker>
))} ))}
{days && days.length > 1 && days.map((day) => { {days && days.length > 1 && days.map((day) => {