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:
parent
6871b154db
commit
c6ae08676b
1 changed files with 81 additions and 42 deletions
|
|
@ -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 L from "leaflet";
|
||||
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 {
|
||||
yjs: YjsState;
|
||||
sessionId: string;
|
||||
|
|
@ -93,6 +157,9 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition,
|
|||
const [noGoDrawing, setNoGoDrawing] = useState(false);
|
||||
const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []);
|
||||
const suppressMapClickRef = useRef(false);
|
||||
const handleContextMenu = useCallback((index: number, x: number, y: number) => {
|
||||
setContextMenu({ x, y, index });
|
||||
}, []);
|
||||
const routeInteractionSuspendedRef = useRef(false);
|
||||
const waypointDraggingRef = useRef(false);
|
||||
|
||||
|
|
@ -188,48 +255,20 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition,
|
|||
)}
|
||||
|
||||
{waypoints.map((wp, i) => (
|
||||
<Marker
|
||||
<WaypointMarker
|
||||
key={i}
|
||||
position={[wp.lat, wp.lon]}
|
||||
draggable
|
||||
zIndexOffset={highlightedWaypoint === i ? Z_WAYPOINT_HIGHLIGHTED : Z_WAYPOINT}
|
||||
icon={waypointIcon(i, wp.overnight, highlightedWaypoint === i, !!wp.note)}
|
||||
eventHandlers={{
|
||||
// Consume the click on the marker. Without a click listener,
|
||||
// Leaflet routes the click to the map (which adds a duplicate
|
||||
// waypoint on top of this one). Registering it makes Leaflet
|
||||
// treat the marker as the event target instead.
|
||||
click: (e) => { e.originalEvent.stopPropagation(); },
|
||||
mouseover: () => { routeInteractionSuspendedRef.current = true; },
|
||||
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>
|
||||
index={i}
|
||||
lat={wp.lat}
|
||||
lon={wp.lon}
|
||||
overnight={wp.overnight}
|
||||
highlighted={highlightedWaypoint === i}
|
||||
note={wp.note}
|
||||
onMove={moveWaypoint}
|
||||
onContextMenu={handleContextMenu}
|
||||
suspendRef={routeInteractionSuspendedRef}
|
||||
draggingRef={waypointDraggingRef}
|
||||
undoManager={yjs.undoManager}
|
||||
/>
|
||||
))}
|
||||
|
||||
{days && days.length > 1 && days.map((day) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue