Files
TREK/client/src/components/Map/plannerWaypointDrag.ts
T
elmocito 57ea219a43 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.
2026-10-14 08:39:31 +02:00

125 lines
4.3 KiB
TypeScript

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;
};
}