mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
feat(tours): complete planner follow-up scope
This commit completes the reviewed Tours follow-up scope after the Tours MVP was merged into dev. Included feature blocks: - RC-01: identify Tours in PLAN day rows and show compact Tour facts; - RC-06: search for a city or address in TOUR-PLANNER without hidden writes; - RC-07: reorder Tour waypoints in the left sidebar; - RC-08: drag Tour waypoints on Leaflet, MapLibre and Mapbox with stable viewport, history and routing behavior; - RS-01: persist Tour descriptions and HTTPS information links with local provider classification; - RS-02: persist breaks/additional time and calculated or manually adjusted planned Tour duration without changing scheduling semantics. The implementation preserves: - ordinary Place and Legacy Track behavior; - Tours-off fallback behavior; - existing GPX/import behavior; - Tour assignment and deletion behavior; - calculated routing duration as a separate value; - existing Tour provenance fields; - keyboard/list alternatives for waypoint editing. Not included: - PR1-09 trip-wide Tour summary; - Wanderer integration; - GPX import changes; - new Tour types; - Tour-to-Day drag-and-drop; - further roadmap or Post-Release features. Validation: - focused RC-01/RC-06/RC-07/RC-08/RS-01/RS-02 tests passed; - affected shared/server/client typechecks passed; - migration and generated DB type checks passed; - scoped ESLint passed; - staged diff checks passed; - owner manual tests passed for all six feature blocks.
This commit is contained in:
@@ -94,6 +94,17 @@ function clickOn(el: Element) {
|
||||
})
|
||||
}
|
||||
|
||||
function pointer(type: string, x: number, y: number): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperties(event, {
|
||||
button: { value: 0 },
|
||||
clientX: { value: x },
|
||||
clientY: { value: y },
|
||||
pointerId: { value: 1 },
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
// Two stops a few hundred metres apart in Nara: separate pins at this zoom, not a cluster.
|
||||
const places = [
|
||||
{ ...buildPlace({ id: 1, name: 'Todai-ji', lat: 34.689, lng: 135.8398 }), category_name: null, category_color: null, category_icon: null },
|
||||
@@ -114,6 +125,143 @@ async function renderMap() {
|
||||
}
|
||||
|
||||
describe('MapView on a real Leaflet map', () => {
|
||||
it('RC-08: previews waypoint drags locally and commits only at pointer up without panning', async () => {
|
||||
const waypoint = { id: 'via-1', lat: 34.689, lng: 135.8398, role: 'via' as const }
|
||||
const waypoints = [waypoint]
|
||||
const onMove = vi.fn(() => true)
|
||||
const onSelect = vi.fn()
|
||||
const onMapClick = vi.fn()
|
||||
const view = render(
|
||||
<MapView
|
||||
places={[]}
|
||||
center={[waypoint.lat, waypoint.lng]}
|
||||
zoom={15}
|
||||
tileUrl={RASTER}
|
||||
plannerWaypoints={waypoints}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[waypoint.lat, waypoint.lng]]}
|
||||
routeProfileFocus={null}
|
||||
/>
|
||||
)
|
||||
const map = maps[maps.length - 1]
|
||||
await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull())
|
||||
const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement
|
||||
let marker: L.Marker | null = null
|
||||
map.eachLayer(layer => {
|
||||
if (layer instanceof L.Marker && layer.getElement() === pin) marker = layer
|
||||
})
|
||||
expect(marker).not.toBeNull()
|
||||
const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng])
|
||||
const end = start.add([30, 20])
|
||||
const expected = map.containerPointToLatLng(end)
|
||||
const center = map.getCenter()
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', start.x, start.y))
|
||||
pin.dispatchEvent(mouse('mousedown', start.x, start.y))
|
||||
pin.dispatchEvent(pointer('pointermove', end.x, end.y))
|
||||
})
|
||||
expect(marker!.getLatLng().lat).toBeCloseTo(expected.lat, 6)
|
||||
expect(marker!.getLatLng().lng).toBeCloseTo(expected.lng, 6)
|
||||
expect(onMove).not.toHaveBeenCalled()
|
||||
expect(waypoints[0]).toEqual(waypoint)
|
||||
|
||||
view.rerender(
|
||||
<MapView
|
||||
places={[]}
|
||||
center={[waypoint.lat, waypoint.lng]}
|
||||
zoom={15}
|
||||
tileUrl={RASTER}
|
||||
plannerWaypoints={waypoints}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[waypoint.lat, waypoint.lng]]}
|
||||
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 34.69, lng: 135.84, sampleIndex: 0 }}
|
||||
/>
|
||||
)
|
||||
expect(marker!.getLatLng().lat).toBeCloseTo(expected.lat, 6)
|
||||
expect(marker!.getLatLng().lng).toBeCloseTo(expected.lng, 6)
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerup', end.x, end.y))
|
||||
pin.dispatchEvent(mouse('mouseup', end.x, end.y))
|
||||
pin.dispatchEvent(mouse('click', end.x, end.y))
|
||||
})
|
||||
expect(onMove).toHaveBeenCalledOnce()
|
||||
expect(onMove).toHaveBeenCalledWith(waypoint.id, expected.lat, expected.lng)
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
expect(map.getCenter().lat).toBeCloseTo(center.lat, 6)
|
||||
expect(map.getCenter().lng).toBeCloseTo(center.lng, 6)
|
||||
|
||||
view.rerender(
|
||||
<MapView
|
||||
places={[]}
|
||||
center={[waypoint.lat, waypoint.lng]}
|
||||
zoom={15}
|
||||
tileUrl={RASTER}
|
||||
plannerWaypoints={[{ ...waypoint, lat: expected.lat, lng: expected.lng }]}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[expected.lat, expected.lng]]}
|
||||
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 34.69, lng: 135.84, sampleIndex: 0 }}
|
||||
/>
|
||||
)
|
||||
expect(map.getCenter().lat).toBeCloseTo(center.lat, 6)
|
||||
expect(map.getCenter().lng).toBeCloseTo(center.lng, 6)
|
||||
})
|
||||
|
||||
it('RC-08: cancellation restores the marker and a plain click still selects without affecting map-add', async () => {
|
||||
const waypoint = { id: 'start', lat: 34.689, lng: 135.8398, role: 'start' as const }
|
||||
const onMove = vi.fn(() => true)
|
||||
const onSelect = vi.fn()
|
||||
const onMapClick = vi.fn()
|
||||
const view = render(
|
||||
<MapView
|
||||
places={[]}
|
||||
center={[waypoint.lat, waypoint.lng]}
|
||||
zoom={15}
|
||||
tileUrl={RASTER}
|
||||
plannerWaypoints={[waypoint]}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
/>
|
||||
)
|
||||
const map = maps[maps.length - 1]
|
||||
await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull())
|
||||
const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement
|
||||
let marker: L.Marker | null = null
|
||||
map.eachLayer(layer => {
|
||||
if (layer instanceof L.Marker && layer.getElement() === pin) marker = layer
|
||||
})
|
||||
const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng])
|
||||
const end = start.add([30, 20])
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', start.x, start.y))
|
||||
pin.dispatchEvent(pointer('pointermove', end.x, end.y))
|
||||
pin.dispatchEvent(pointer('pointercancel', end.x, end.y))
|
||||
})
|
||||
expect(marker!.getLatLng().lat).toBeCloseTo(waypoint.lat, 6)
|
||||
expect(marker!.getLatLng().lng).toBeCloseTo(waypoint.lng, 6)
|
||||
expect(onMove).not.toHaveBeenCalled()
|
||||
|
||||
clickOn(pin)
|
||||
expect(onSelect).toHaveBeenCalledOnce()
|
||||
expect(onSelect).toHaveBeenCalledWith(waypoint.id)
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
act(() => map.fire('click', { latlng: L.latLng(34.7, 135.85) }))
|
||||
expect(onMapClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses WGS-84 for Tours-local Topo over global Amap and preserves a Beijing click through layer switches', async () => {
|
||||
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' } })
|
||||
const beijing = { ...buildPlace({ id: 201, name: 'Beijing', lat: 39.9042, lng: 116.4074 }), category_name: null, category_color: null, category_icon: null }
|
||||
|
||||
@@ -442,6 +442,48 @@ describe('MapView', () => {
|
||||
expect(mapMock.fitBounds).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors a location-search camera intent and preserves it through later route updates', () => {
|
||||
const searchPoint: [number, number] = [35.0116, 135.7681];
|
||||
const { rerender } = render(
|
||||
<MapView
|
||||
places={[]}
|
||||
route={null}
|
||||
followSelection={false}
|
||||
focusKey="tour:0:search:1"
|
||||
focusPoints={[searchPoint]}
|
||||
/>
|
||||
);
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 16 }));
|
||||
const mapContainer = screen.getByTestId('map-container');
|
||||
mapMock.fitBounds.mockClear();
|
||||
|
||||
rerender(
|
||||
<MapView
|
||||
places={[]}
|
||||
route={[[searchPoint, [35.02, 135.77]]]}
|
||||
followSelection={false}
|
||||
focusKey="tour:0:search:1"
|
||||
focusPoints={[searchPoint, [35.02, 135.77]]}
|
||||
/>
|
||||
);
|
||||
expect(mapMock.fitBounds).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('map-container')).toBe(mapContainer);
|
||||
|
||||
rerender(
|
||||
<MapView
|
||||
places={[]}
|
||||
followSelection={false}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[
|
||||
[46.8, 9.5],
|
||||
[47.1, 10.1],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', () => {
|
||||
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -6,6 +6,7 @@ import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag'
|
||||
import HazardLayers from './HazardLayers'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
@@ -449,7 +450,7 @@ interface BoundsControllerProps {
|
||||
*/
|
||||
focusPoints?: [number, number][]
|
||||
/** Changes only when the caller intentionally wants a new initial frame. */
|
||||
focusKey?: number
|
||||
focusKey?: number | string
|
||||
/** False while the map is locked (#2010): an arriving route no longer re-fits it. */
|
||||
follow?: boolean
|
||||
/**
|
||||
@@ -757,16 +758,71 @@ const MemoMarker = memo(function MemoMarker({
|
||||
)
|
||||
})
|
||||
|
||||
function plannerWaypointIcon(index: number, selected: boolean): L.DivIcon {
|
||||
function plannerWaypointIcon(index: number, selected: boolean, draggable: boolean): L.DivIcon {
|
||||
const size = selected ? 30 : 26
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
className: 'tour-planner-waypoint-marker',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
html: `<span style="display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer">${index + 1}</span>`,
|
||||
html: `<span style="display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
|
||||
})
|
||||
}
|
||||
|
||||
function PlannerWaypointMarker({
|
||||
point,
|
||||
index,
|
||||
selected,
|
||||
onClick,
|
||||
onMove,
|
||||
}: {
|
||||
point: { id: string; lat: number; lng: number };
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onClick?: (id: string) => void;
|
||||
onMove?: (id: string, lat: number, lng: number) => boolean;
|
||||
}) {
|
||||
const map = useMap()
|
||||
const [marker, setMarker] = useState<L.Marker | null>(null)
|
||||
const handlersRef = useRef({ onClick, onMove })
|
||||
handlersRef.current = { onClick, onMove }
|
||||
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng])
|
||||
const icon = useMemo(() => plannerWaypointIcon(index, selected, !!onMove), [index, onMove, selected])
|
||||
const eventHandlers = useMemo(() => ({
|
||||
click: (event: { originalEvent: MouseEvent }) => {
|
||||
event.originalEvent.stopPropagation()
|
||||
handlersRef.current.onClick?.(point.id)
|
||||
},
|
||||
}), [point.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!marker || !onMove) return
|
||||
const element = marker.getElement()
|
||||
return wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial: { lat: point.lat, lng: point.lng },
|
||||
coordinateAt: (clientX, clientY) => {
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
const at = map.containerPointToLatLng([clientX - rect.left, clientY - rect.top])
|
||||
return { lat: at.lat, lng: at.lng }
|
||||
},
|
||||
setPosition: ({ lat, lng }) => { marker.setLatLng([lat, lng]) },
|
||||
onCommit: ({ lat, lng }) => handlersRef.current.onMove?.(point.id, lat, lng) ?? false,
|
||||
})
|
||||
}, [map, marker, onMove, point.id, point.lat, point.lng])
|
||||
|
||||
return (
|
||||
<Marker
|
||||
ref={setMarker}
|
||||
position={position}
|
||||
icon={icon}
|
||||
zIndexOffset={selected ? 1200 : 1100}
|
||||
eventHandlers={eventHandlers}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -12]}>{index + 1}</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
}
|
||||
|
||||
export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
@@ -836,6 +892,7 @@ export const MapView = memo(function MapView({
|
||||
plannerWaypoints = [],
|
||||
selectedPlannerWaypointId = null,
|
||||
onPlannerWaypointClick,
|
||||
onPlannerWaypointMove,
|
||||
routeProfileFocus = null,
|
||||
viewBaseLayer,
|
||||
onViewBaseLayerChange,
|
||||
@@ -1307,20 +1364,14 @@ export const MapView = memo(function MapView({
|
||||
) : null}
|
||||
|
||||
{(plannerWaypoints as Array<{ id: string; lat: number; lng: number }>).map((point, index) => (
|
||||
<Marker
|
||||
<PlannerWaypointMarker
|
||||
key={`planner-waypoint-${point.id}`}
|
||||
position={[point.lat, point.lng]}
|
||||
icon={plannerWaypointIcon(index, point.id === selectedPlannerWaypointId)}
|
||||
zIndexOffset={point.id === selectedPlannerWaypointId ? 1200 : 1100}
|
||||
eventHandlers={{
|
||||
click: (event: { originalEvent: MouseEvent }) => {
|
||||
event.originalEvent.stopPropagation()
|
||||
onPlannerWaypointClick?.(point.id)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -12]}>{index + 1}</Tooltip>
|
||||
</Marker>
|
||||
point={point}
|
||||
index={index}
|
||||
selected={point.id === selectedPlannerWaypointId}
|
||||
onClick={onPlannerWaypointClick}
|
||||
onMove={onPlannerWaypointMove}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
|
||||
@@ -47,6 +47,7 @@ const glMap = vi.hoisted(() => ({
|
||||
getStyle: vi.fn().mockReturnValue({ layers: [] }),
|
||||
isStyleLoaded: vi.fn().mockReturnValue(true),
|
||||
getCanvasContainer: vi.fn(() => glCanvasContainer),
|
||||
getContainer: vi.fn(() => glCanvasContainer),
|
||||
getLayer: vi.fn().mockReturnValue(null),
|
||||
queryRenderedFeatures: vi.fn().mockReturnValue([]),
|
||||
querySourceFeatures: vi.fn().mockReturnValue([]),
|
||||
@@ -246,6 +247,17 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
|
||||
} as any
|
||||
}
|
||||
|
||||
function pointer(type: string, x: number, y: number): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperties(event, {
|
||||
button: { value: 0 },
|
||||
clientX: { value: x },
|
||||
clientY: { value: y },
|
||||
pointerId: { value: 1 },
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
glMap.on.mockImplementation(() => glMap)
|
||||
glMap.off.mockImplementation(() => glMap)
|
||||
@@ -931,6 +943,55 @@ describe('MapViewGL', () => {
|
||||
expect(glMap.easeTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors a location-search camera intent and preserves it through later route updates', async () => {
|
||||
loadOnAttach();
|
||||
const searchPoint: [number, number] = [35.0116, 135.7681];
|
||||
const { rerender } = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
route={null}
|
||||
followSelection={false}
|
||||
focusKey="tour:0:search:1"
|
||||
focusPoints={[searchPoint]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 15 }));
|
||||
const mapConstructions = (maplibregl.Map as any).mock.calls.length;
|
||||
glMap.fitBounds.mockClear();
|
||||
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
route={[[searchPoint, [35.02, 135.77]]]}
|
||||
followSelection={false}
|
||||
focusKey="tour:0:search:1"
|
||||
focusPoints={[searchPoint, [35.02, 135.77]]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).not.toHaveBeenCalled();
|
||||
expect((maplibregl.Map as any).mock.calls).toHaveLength(mapConstructions);
|
||||
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
followSelection={false}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[
|
||||
[46.8, 9.5],
|
||||
[47.1, 10.1],
|
||||
]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', async () => {
|
||||
loadOnAttach()
|
||||
const { rerender } = render(
|
||||
@@ -982,6 +1043,126 @@ describe('MapViewGL', () => {
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['maplibre-gl', 'mapbox-gl'] as const)('RC-08: %s previews waypoint drags locally and commits only on pointer up without changing the camera', async (provider) => {
|
||||
loadOnAttach()
|
||||
glMap.project.mockImplementation((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 }))
|
||||
glMap.unproject.mockReturnValue({ lng: 2.3522, lat: 48.8566 })
|
||||
glMap.getContainer.mockReturnValue(glCanvasContainer)
|
||||
const waypoint = { id: 'via-1', lat: 48, lng: 11, role: 'via' as const }
|
||||
const waypoints = [waypoint]
|
||||
const onMove = vi.fn(() => true)
|
||||
const onSelect = vi.fn()
|
||||
const onMapClick = vi.fn()
|
||||
const view = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={waypoints}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[waypoint.lat, waypoint.lng]]}
|
||||
routeProfileFocus={null}
|
||||
glProvider={provider}
|
||||
/>
|
||||
)
|
||||
await act(async () => {})
|
||||
glMap.fitBounds.mockClear()
|
||||
const pin = provider === 'maplibre-gl'
|
||||
? glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement
|
||||
: glMarkers.created.find(marker => marker.element.getAttribute('aria-label') === 'Waypoint 1')?.element as HTMLButtonElement
|
||||
const startTransform = pin.style.transform
|
||||
const libraryMarker = provider === 'mapbox-gl' ? glMarkers.created.find(marker => marker.element === pin) : undefined
|
||||
const startLngLat = libraryMarker?.lngLat
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', 10, 10))
|
||||
pin.dispatchEvent(pointer('pointermove', 30, 40))
|
||||
})
|
||||
if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform)
|
||||
else expect(libraryMarker?.lngLat).not.toEqual(startLngLat)
|
||||
expect(onMove).not.toHaveBeenCalled()
|
||||
expect(waypoints[0]).toEqual(waypoint)
|
||||
|
||||
view.rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={waypoints}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[waypoint.lat, waypoint.lng]]}
|
||||
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
|
||||
glProvider={provider}
|
||||
/>
|
||||
)
|
||||
if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform)
|
||||
else expect(libraryMarker?.lngLat).not.toEqual(startLngLat)
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerup', 30, 40))
|
||||
pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
})
|
||||
expect(onMove).toHaveBeenCalledOnce()
|
||||
expect(onMove).toHaveBeenCalledWith(waypoint.id, 48.8566, 2.3522)
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
view.rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={[{ ...waypoint, lat: 48.4, lng: 11.3 }]}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
focusKey="tour:1"
|
||||
focusPoints={[[48.4, 11.3]]}
|
||||
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
|
||||
glProvider={provider}
|
||||
/>
|
||||
)
|
||||
expect(glMap.fitBounds).not.toHaveBeenCalled()
|
||||
expect(glMap.flyTo).not.toHaveBeenCalled()
|
||||
expect(glMap.jumpTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('RC-08: pointer cancellation restores the MapLibre pin and plain clicks still select and add separately', async () => {
|
||||
loadOnAttach()
|
||||
const waypoint = { id: 'start', lat: 48, lng: 11, role: 'start' as const }
|
||||
const onMove = vi.fn(() => true)
|
||||
const onSelect = vi.fn()
|
||||
const onMapClick = vi.fn()
|
||||
render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
plannerWaypoints={[waypoint]}
|
||||
onPlannerWaypointClick={onSelect}
|
||||
onPlannerWaypointMove={onMove}
|
||||
onMapClick={onMapClick}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
)
|
||||
await act(async () => {})
|
||||
const pin = glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement
|
||||
const initialTransform = pin.style.transform
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', 10, 10))
|
||||
pin.dispatchEvent(pointer('pointermove', 30, 40))
|
||||
pin.dispatchEvent(pointer('pointercancel', 30, 40))
|
||||
})
|
||||
expect(pin.style.transform).toBe(initialTransform)
|
||||
expect(onMove).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerdown', 10, 10))
|
||||
pin.dispatchEvent(pointer('pointerup', 10, 10))
|
||||
pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
})
|
||||
expect(onSelect).toHaveBeenCalledOnce()
|
||||
expect(onSelect).toHaveBeenCalledWith(waypoint.id)
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps selection fit and the pending route-arrival refit for semantic changes', async () => {
|
||||
loadOnAttach()
|
||||
const selectedPlace = buildPlace({ id: 902, lat: 48, lng: 11 })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
@@ -622,6 +623,7 @@ export function MapViewGL({
|
||||
plannerWaypoints = [],
|
||||
selectedPlannerWaypointId = null,
|
||||
onPlannerWaypointClick,
|
||||
onPlannerWaypointMove,
|
||||
routeProfileFocus = null,
|
||||
viewBaseLayer,
|
||||
onViewBaseLayerChange,
|
||||
@@ -708,6 +710,9 @@ export function MapViewGL({
|
||||
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
|
||||
const viaPinsRef = useRef<PlacePin[]>([])
|
||||
const plannerWaypointPinsRef = useRef<PlacePin[]>([])
|
||||
const plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove })
|
||||
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove }
|
||||
const plannerWaypointsDraggable = !!onPlannerWaypointMove
|
||||
const profileFocusPinRef = useRef<PlacePin | null>(null)
|
||||
/** The drive-time pills on the offered routes; same treatment. */
|
||||
const altLabelsRef = useRef<PlacePin[]>([])
|
||||
@@ -744,6 +749,7 @@ export function MapViewGL({
|
||||
if (!map || !mapReady) return
|
||||
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
|
||||
plannerWaypointPinsRef.current = []
|
||||
const dragCleanups: (() => void)[] = []
|
||||
|
||||
for (const [index, point] of plannerWaypoints.entries()) {
|
||||
const selected = point.id === selectedPlannerWaypointId
|
||||
@@ -754,18 +760,34 @@ export function MapViewGL({
|
||||
el.setAttribute('aria-label', `Waypoint ${index + 1}`)
|
||||
el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer;padding:0`
|
||||
const swallow = (event: Event) => event.stopPropagation()
|
||||
const select = (event: Event) => { event.stopPropagation(); onPlannerWaypointClick?.(point.id) }
|
||||
const select = (event: Event) => { event.stopPropagation(); plannerWaypointHandlersRef.current.onPlannerWaypointClick?.(point.id) }
|
||||
el.addEventListener('pointerdown', swallow)
|
||||
el.addEventListener('touchstart', swallow, { passive: true })
|
||||
el.addEventListener('click', select)
|
||||
plannerWaypointPinsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat))
|
||||
const pin = attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat)
|
||||
plannerWaypointPinsRef.current.push(pin)
|
||||
if (plannerWaypointsDraggable) {
|
||||
el.style.cursor = 'grab'
|
||||
dragCleanups.push(wirePlannerWaypointDrag({
|
||||
element: el,
|
||||
initial: { lat: point.lat, lng: point.lng },
|
||||
coordinateAt: (clientX, clientY) => {
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
const at = map.unproject([clientX - rect.left, clientY - rect.top])
|
||||
return { lat: at.lat, lng: at.lng }
|
||||
},
|
||||
setPosition: ({ lat, lng }) => pin.setLngLat([lng, lat]),
|
||||
onCommit: ({ lat, lng }) => plannerWaypointHandlersRef.current.onPlannerWaypointMove?.(point.id, lat, lng) ?? false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
dragCleanups.forEach(cleanup => cleanup())
|
||||
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
|
||||
plannerWaypointPinsRef.current = []
|
||||
}
|
||||
}, [gl, mapReady, onPlannerWaypointClick, plannerWaypoints, selectedPlannerWaypointId])
|
||||
}, [gl, mapReady, plannerWaypoints, plannerWaypointsDraggable, selectedPlannerWaypointId])
|
||||
|
||||
useEffect(() => () => {
|
||||
profileFocusPinRef.current?.remove()
|
||||
|
||||
@@ -111,6 +111,8 @@ export interface MapViewProps {
|
||||
plannerWaypoints?: Array<{ id: string; lat: number; lng: number }>;
|
||||
selectedPlannerWaypointId?: string | null;
|
||||
onPlannerWaypointClick?: (id: string) => void;
|
||||
/** Drops a dragged control point; returns false when the move was refused. */
|
||||
onPlannerWaypointMove?: (id: string, lat: number, lng: number) => boolean;
|
||||
routeProfileFocus?: RouteProfileFocus | null;
|
||||
viewBaseLayer?: TourBaseLayer;
|
||||
onViewBaseLayerChange?: (layer: TourBaseLayer) => void;
|
||||
@@ -123,7 +125,7 @@ export interface MapViewProps {
|
||||
*/
|
||||
focusPoints?: [number, number][];
|
||||
/** Changes only when the caller intentionally wants a new initial frame. */
|
||||
focusKey?: number;
|
||||
focusKey?: number | string;
|
||||
/**
|
||||
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
function pointer(type: string, x: number, y: number, pointerId = 1, pointerType: 'mouse' | 'touch' = 'mouse'): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperties(event, {
|
||||
button: { value: 0 },
|
||||
clientX: { value: x },
|
||||
clientY: { value: y },
|
||||
pointerId: { value: pointerId },
|
||||
pointerType: { value: pointerType },
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('Tour waypoint marker drag lifecycle', () => {
|
||||
it('previews during a drag and commits once on pointer up, swallowing only the generated click', () => {
|
||||
const element = document.createElement('button');
|
||||
const initial = { lat: 48, lng: 11 };
|
||||
const preview = vi.fn();
|
||||
const onCommit = vi.fn(() => true);
|
||||
const cleanup = wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial,
|
||||
coordinateAt: (clientX, clientY) => ({ lat: clientY / 100, lng: clientX / 100 }),
|
||||
setPosition: preview,
|
||||
onCommit,
|
||||
});
|
||||
|
||||
element.dispatchEvent(pointer('pointerdown', 10, 10));
|
||||
element.dispatchEvent(pointer('pointermove', 11, 11));
|
||||
expect(preview).not.toHaveBeenCalled();
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
|
||||
element.dispatchEvent(pointer('pointermove', 20, 30));
|
||||
expect(preview).toHaveBeenLastCalledWith({ lat: 0.3, lng: 0.2 });
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
element.dispatchEvent(pointer('pointerup', 30, 40));
|
||||
expect(preview).toHaveBeenLastCalledWith({ lat: 0.4, lng: 0.3 });
|
||||
expect(onCommit).toHaveBeenCalledOnce();
|
||||
expect(onCommit).toHaveBeenCalledWith({ lat: 0.4, lng: 0.3 });
|
||||
|
||||
const generatedClick = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||
element.dispatchEvent(generatedClick);
|
||||
expect(generatedClick.defaultPrevented).toBe(true);
|
||||
const nextClick = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||
element.dispatchEvent(nextClick);
|
||||
expect(nextClick.defaultPrevented).toBe(false);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('restores the starting coordinate on pointer cancellation and does not commit', () => {
|
||||
const element = document.createElement('button');
|
||||
const initial = { lat: 48, lng: 11 };
|
||||
const preview = vi.fn();
|
||||
const onCommit = vi.fn();
|
||||
const cleanup = wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial,
|
||||
coordinateAt: (clientX, clientY) => ({ lat: clientY, lng: clientX }),
|
||||
setPosition: preview,
|
||||
onCommit,
|
||||
});
|
||||
|
||||
element.dispatchEvent(pointer('pointerdown', 10, 10));
|
||||
element.dispatchEvent(pointer('pointermove', 20, 30));
|
||||
element.dispatchEvent(pointer('pointercancel', 20, 30));
|
||||
expect(preview).toHaveBeenLastCalledWith(initial);
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('restores the preview when the canonical commit is rejected', () => {
|
||||
const element = document.createElement('button');
|
||||
const initial = { lat: 48, lng: 11 };
|
||||
const preview = vi.fn();
|
||||
const cleanup = wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial,
|
||||
coordinateAt: (clientX, clientY) => ({ lat: clientY, lng: clientX }),
|
||||
setPosition: preview,
|
||||
onCommit: () => false,
|
||||
});
|
||||
|
||||
element.dispatchEvent(pointer('pointerdown', 10, 10));
|
||||
element.dispatchEvent(pointer('pointermove', 20, 30));
|
||||
element.dispatchEvent(pointer('pointerup', 20, 30));
|
||||
expect(preview).toHaveBeenLastCalledWith(initial);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('keeps pointer, mouse, and touch starts from bubbling into map pan handlers', () => {
|
||||
const mapSurface = document.createElement('div');
|
||||
const element = document.createElement('button');
|
||||
mapSurface.append(element);
|
||||
const mapPan = vi.fn();
|
||||
mapSurface.addEventListener('pointerdown', mapPan);
|
||||
mapSurface.addEventListener('mousedown', mapPan);
|
||||
mapSurface.addEventListener('touchstart', mapPan);
|
||||
const cleanup = wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial: { lat: 48, lng: 11 },
|
||||
coordinateAt: () => ({ lat: 48, lng: 11 }),
|
||||
setPosition: vi.fn(),
|
||||
onCommit: vi.fn(),
|
||||
});
|
||||
|
||||
element.dispatchEvent(pointer('pointerdown', 10, 10));
|
||||
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }));
|
||||
element.dispatchEvent(new Event('touchstart', { bubbles: true, cancelable: true }));
|
||||
expect(mapPan).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('commits a touch-pointer drag once at release', () => {
|
||||
const element = document.createElement('button');
|
||||
const onCommit = vi.fn(() => true);
|
||||
const cleanup = wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial: { lat: 48, lng: 11 },
|
||||
coordinateAt: (clientX, clientY) => ({ lat: clientY / 10, lng: clientX / 10 }),
|
||||
setPosition: vi.fn(),
|
||||
onCommit,
|
||||
});
|
||||
|
||||
element.dispatchEvent(pointer('pointerdown', 5, 5, 9, 'touch'));
|
||||
element.dispatchEvent(pointer('pointermove', 25, 35, 9, 'touch'));
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
element.dispatchEvent(pointer('pointerup', 25, 35, 9, 'touch'));
|
||||
expect(onCommit).toHaveBeenCalledOnce();
|
||||
expect(onCommit).toHaveBeenCalledWith({ lat: 3.5, lng: 2.5 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('restores existing touch and selection styles when editing drag wiring is removed', () => {
|
||||
const element = document.createElement('button');
|
||||
element.style.touchAction = 'pan-x';
|
||||
element.style.userSelect = 'text';
|
||||
const cleanup = wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial: { lat: 48, lng: 11 },
|
||||
coordinateAt: () => ({ lat: 48, lng: 11 }),
|
||||
setPosition: vi.fn(),
|
||||
onCommit: vi.fn(),
|
||||
});
|
||||
expect(element.style.touchAction).toBe('none');
|
||||
expect(element.style.userSelect).toBe('none');
|
||||
cleanup();
|
||||
expect(element.style.touchAction).toBe('pan-x');
|
||||
expect(element.style.userSelect).toBe('text');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
export interface PlannerWaypointCoordinate {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
interface PlannerWaypointDragOptions {
|
||||
element: HTMLElement;
|
||||
initial: PlannerWaypointCoordinate;
|
||||
coordinateAt: (clientX: number, clientY: number) => PlannerWaypointCoordinate;
|
||||
setPosition: (coordinate: PlannerWaypointCoordinate) => void;
|
||||
onCommit: (coordinate: PlannerWaypointCoordinate) => boolean | void;
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
export function wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial,
|
||||
coordinateAt,
|
||||
setPosition,
|
||||
onCommit,
|
||||
}: PlannerWaypointDragOptions): () => void {
|
||||
let pointerId: number | null = null;
|
||||
let start: { x: number; y: number } | null = null;
|
||||
let dragged = false;
|
||||
let suppressClick = false;
|
||||
const originalTouchAction = element.style.touchAction;
|
||||
const originalUserSelect = element.style.userSelect;
|
||||
|
||||
const stopMapGesture = (event: Event) => event.stopPropagation();
|
||||
const restore = () => setPosition(initial);
|
||||
const cancel = (event?: Event) => {
|
||||
if (pointerId === null) return;
|
||||
event?.stopPropagation();
|
||||
restore();
|
||||
pointerId = null;
|
||||
start = null;
|
||||
dragged = false;
|
||||
suppressClick = false;
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0 || pointerId !== null) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
pointerId = event.pointerId;
|
||||
start = { x: event.clientX, y: event.clientY };
|
||||
dragged = false;
|
||||
suppressClick = false;
|
||||
try {
|
||||
element.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Pointer capture is unavailable in some test DOMs and older browsers.
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId || !start) return;
|
||||
event.stopPropagation();
|
||||
if (!dragged && Math.hypot(event.clientX - start.x, event.clientY - start.y) < DRAG_THRESHOLD_PX) return;
|
||||
dragged = true;
|
||||
event.preventDefault();
|
||||
setPosition(coordinateAt(event.clientX, event.clientY));
|
||||
};
|
||||
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId || !start) return;
|
||||
event.stopPropagation();
|
||||
const travelled = Math.hypot(event.clientX - start.x, event.clientY - start.y);
|
||||
if (dragged || travelled >= DRAG_THRESHOLD_PX) {
|
||||
const coordinate = coordinateAt(event.clientX, event.clientY);
|
||||
setPosition(coordinate);
|
||||
const accepted = onCommit(coordinate) !== false;
|
||||
suppressClick = true;
|
||||
if (!accepted) restore();
|
||||
}
|
||||
pointerId = null;
|
||||
start = null;
|
||||
dragged = false;
|
||||
};
|
||||
|
||||
const onClickCapture = (event: MouseEvent) => {
|
||||
if (!suppressClick) return;
|
||||
suppressClick = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const onTouchCancel = (event: Event) => cancel(event);
|
||||
|
||||
element.addEventListener('pointerdown', onPointerDown);
|
||||
element.addEventListener('pointermove', onPointerMove);
|
||||
element.addEventListener('pointerup', onPointerUp);
|
||||
element.addEventListener('pointercancel', cancel);
|
||||
element.addEventListener('lostpointercapture', cancel);
|
||||
element.addEventListener('click', onClickCapture, true);
|
||||
element.addEventListener('mousedown', stopMapGesture);
|
||||
element.addEventListener('touchstart', stopMapGesture, { passive: true });
|
||||
element.addEventListener('touchmove', stopMapGesture, { passive: true });
|
||||
element.addEventListener('touchend', stopMapGesture, { passive: true });
|
||||
element.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
||||
element.style.touchAction = 'none';
|
||||
element.style.userSelect = 'none';
|
||||
|
||||
return () => {
|
||||
cancel();
|
||||
element.removeEventListener('pointerdown', onPointerDown);
|
||||
element.removeEventListener('pointermove', onPointerMove);
|
||||
element.removeEventListener('pointerup', onPointerUp);
|
||||
element.removeEventListener('pointercancel', cancel);
|
||||
element.removeEventListener('lostpointercapture', cancel);
|
||||
element.removeEventListener('click', onClickCapture, true);
|
||||
element.removeEventListener('mousedown', stopMapGesture);
|
||||
element.removeEventListener('touchstart', stopMapGesture);
|
||||
element.removeEventListener('touchmove', stopMapGesture);
|
||||
element.removeEventListener('touchend', stopMapGesture);
|
||||
element.removeEventListener('touchcancel', onTouchCancel);
|
||||
element.style.touchAction = originalTouchAction;
|
||||
element.style.userSelect = originalUserSelect;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user