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 { 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) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue