airport-editor: fix selection clearing on feature click (Leaflet _stopped)

Root cause: clicking a taxiway selected it (blue highlight) then the same
DOM click re-fired on the map and cleared selection, so handles never stayed
visible and vertex drag could not run.

Leaflet only stops map re-fire when originalEvent._stopped is set; native
stopPropagation alone does not set that flag. Set _stopped, disable path
bubblingMouseEvents, and gate map clicks after feature clicks. Disable boxZoom
(blue rectangle). Cache-bust static module URLs (assets are go:embed).
This commit is contained in:
Reese Norris
2026-07-27 15:22:44 -04:00
parent 1e5127874a
commit e8c6965fe0
4 changed files with 121 additions and 9 deletions

View File

@@ -763,7 +763,7 @@ function main() {
/** Status banner for surface select in Select mode (not mid-drag). */
const SURFACE_SELECT_TIP =
'Drag white handles to move vertices. Click empty map to deselect.';
'Selected — drag the white dots to move vertices. Click empty map to deselect.';
/**
* Show tip when selection is a surface; clear it when selection leaves a

View File

@@ -209,11 +209,18 @@ export const VERTEX_HANDLE_PX = 18;
* Pixel radius for vertex grab hit-test (capture-phase, independent of icon DOM hits).
* Slightly larger than the visible handle so corners are easy to grab.
*/
export const VERTEX_HIT_PX = 16;
export const VERTEX_HIT_PX = 20;
/** Duration after dragend during which map clicks are ignored. */
export const MAP_CLICK_SUPPRESS_MS = 250;
/**
* After a feature (surface/aircraft) click, ignore map clicks for this long.
* Leaflet re-fires the same DOM click onto the map unless originalEvent._stopped
* is set; we do both _stopped and this time gate.
*/
export const FEATURE_CLICK_SUPPRESS_MS = 100;
/**
* Leaflet pane name for vertex handle visuals.
* createPane("aptedVertex") → CSS class "leaflet-aptedVertex-pane"
@@ -356,9 +363,41 @@ export function shouldSuppressMapClick(state, now = Date.now()) {
) {
return true;
}
if (
state.featureClickAt > 0 &&
now - state.featureClickAt < (state.featureSuppressMs ?? FEATURE_CLICK_SUPPRESS_MS)
) {
return true;
}
return false;
}
/**
* Stop a Leaflet layer mouse event from also firing on the map.
*
* Leaflet walks targets and continues while !originalEvent._stopped. Calling
* native stopPropagation() alone does NOT set _stopped on modern browsers, so
* map click still runs (and was clearing selection immediately after select).
*
* @param {*} ev Leaflet event with optional originalEvent
* @param {typeof globalThis.L} [L]
*/
export function stopLeafletClickBubble(ev, L) {
if (!ev) return;
const dom = ev.originalEvent;
if (dom) {
// Leaflet propagation flag (required — see _fireDOMEvent in leaflet.js).
dom._stopped = true;
if (L && L.DomEvent) {
L.DomEvent.stopPropagation(dom);
L.DomEvent.preventDefault(dom);
} else {
if (typeof dom.stopPropagation === 'function') dom.stopPropagation();
if (typeof dom.preventDefault === 'function') dom.preventDefault();
}
}
}
/**
* Build a Leaflet tooltip content node with textContent only (never innerHTML).
* Leaflet 1.9 uses innerHTML for string content — always pass an Element.
@@ -447,12 +486,18 @@ export function createMap(L, mapEl, opts = {}) {
const zoom = opts.zoom ?? 2;
const map = L.map(mapEl, {
// Keep default attributionControl: true
// Box-zoom (shift-drag blue rectangle) confuses editors; pan/zoom still work.
boxZoom: false,
}).setView(center, zoom);
// Cosmetic only — must not remove attribution control or empty tile credits.
if (map.attributionControl && typeof map.attributionControl.setPrefix === 'function') {
map.attributionControl.setPrefix('');
}
// Belt-and-suspenders if boxZoom was already constructed.
if (map.boxZoom && typeof map.boxZoom.disable === 'function') {
map.boxZoom.disable();
}
const baseLayers = createBaseLayers(L, { blank: !!opts.blankTiles });
let activeBase = baseLayers.defaultKey;
@@ -569,6 +614,8 @@ export class OverlayController {
this._dragging = false;
/** @type {number} ms timestamp of last dragend (0 = never / reset on dragstart) */
this._dragEndedAt = 0;
/** @type {number} ms timestamp of last feature click (blocks map deselect) */
this._featureClickAt = 0;
/**
* Active manual vertex drag.
* @type {{ surfaceIndex: number, vertexIndex: number, handle: *|null, mapDraggingWasEnabled: boolean }|null}
@@ -611,6 +658,8 @@ export class OverlayController {
dragging: this._dragging,
dragEndedAt: this._dragEndedAt,
suppressMs: MAP_CLICK_SUPPRESS_MS,
featureClickAt: this._featureClickAt,
featureSuppressMs: FEATURE_CLICK_SUPPRESS_MS,
},
Date.now(),
)
@@ -750,6 +799,11 @@ export class OverlayController {
const key = `surface:${index}`;
let layer;
// bubblingMouseEvents:false — do not re-fire click on the map (would clear selection).
const pathOpts = {
bubblingMouseEvents: false,
};
if (surface.kind === SurfaceParking || (surface.kind === SurfaceHold && pts.length === 1)) {
const p = pts[0];
layer = L.circleMarker([p.lat, p.lon], {
@@ -759,6 +813,7 @@ export class OverlayController {
opacity: style.opacity,
fillColor: style.fillColor || style.color,
fillOpacity: style.fillOpacity ?? 0.85,
...pathOpts,
});
} else {
// Multi-point surfaces (runway/taxi/hold polyline). Hold dash is in style.
@@ -768,12 +823,14 @@ export class OverlayController {
weight: style.weight,
opacity: style.opacity,
dashArray: style.dashArray,
...pathOpts,
});
}
bindTextTooltip(layer, surfaceLabel(surface));
layer.on('click', (ev) => {
if (ev.originalEvent) L.DomEvent.stopPropagation(ev.originalEvent);
stopLeafletClickBubble(ev, L);
this._featureClickAt = Date.now();
this.onSelect({ type: 'surface', index });
});
layer.addTo(this.group);
@@ -1079,7 +1136,8 @@ export class OverlayController {
const tip = `${ac.callsign || '?'} · ${ac.type || ''} · hdg ${hdg}`;
bindTextTooltip(marker, tip);
marker.on('click', (ev) => {
if (ev.originalEvent) L.DomEvent.stopPropagation(ev.originalEvent);
stopLeafletClickBubble(ev, L);
this._featureClickAt = Date.now();
this.onSelect({ type: 'aircraft', index });
});
if (this.editable) {

View File

@@ -208,7 +208,11 @@
</div>
</main>
<script src="/static/js/leaflet.js"></script>
<script src="/static/js/openfsd/leaflet.rotatedmarker.js"></script>
<script type="module" src="/static/js/openfsd/airport-editor/main.js"></script>
{{/*
Static assets are go:embed into the openfsd binary — restart the process after
JS/CSS changes. Query string busts browser cache when the binary is rebuilt.
*/}}
<script src="/static/js/leaflet.js?v=vertex-edit-3"></script>
<script src="/static/js/openfsd/leaflet.rotatedmarker.js?v=vertex-edit-3"></script>
<script type="module" src="/static/js/openfsd/airport-editor/main.js?v=vertex-edit-3"></script>
{{ end }}

View File

@@ -26,13 +26,15 @@ import {
VERTEX_HIT_PX,
VERTEX_PANE,
MAP_CLICK_SUPPRESS_MS,
FEATURE_CLICK_SUPPRESS_MS,
buildVertexHandleOptions,
buildVertexHandleIconOptions,
findNearestVertexPx,
shouldSuppressMapClick,
stopLeafletClickBubble,
pointsToLatLngs,
applySurfaceLatLngs,
patchVertexPoints,
shouldSuppressMapClick,
} from '../../internal/web/static/js/openfsd/airport-editor/map-layers.js';
import {
createEmptyDocument,
@@ -306,6 +308,54 @@ test('shouldSuppressMapClick matrix (K5: suppress ≠ block render)', () => {
shouldSuppressMapClick({ dragging: false, dragEndedAt: 0, suppressMs: ms }, 5000),
false,
);
// Feature click must suppress the trailing map click (selection stick fix)
assert.equal(FEATURE_CLICK_SUPPRESS_MS, 100);
assert.equal(
shouldSuppressMapClick(
{
dragging: false,
dragEndedAt: 0,
suppressMs: ms,
featureClickAt: 2000,
featureSuppressMs: FEATURE_CLICK_SUPPRESS_MS,
},
2000 + 50,
),
true,
);
assert.equal(
shouldSuppressMapClick(
{
dragging: false,
dragEndedAt: 0,
suppressMs: ms,
featureClickAt: 2000,
featureSuppressMs: FEATURE_CLICK_SUPPRESS_MS,
},
2000 + FEATURE_CLICK_SUPPRESS_MS,
),
false,
);
});
test('stopLeafletClickBubble sets originalEvent._stopped (Leaflet map bubble guard)', () => {
const dom = {
stopPropagation() {
this.stoppedNative = true;
},
preventDefault() {
this.prevented = true;
},
};
const ev = { originalEvent: dom };
stopLeafletClickBubble(ev, null);
assert.equal(dom._stopped, true);
assert.equal(dom.stoppedNative, true);
assert.equal(dom.prevented, true);
// no throw on empty
stopLeafletClickBubble(null, null);
stopLeafletClickBubble({}, null);
});
test('buildVertexHandleIconOptions: iconSize/iconAnchor symmetry', () => {
@@ -334,7 +384,7 @@ test('buildVertexHandleOptions: visual-only (capture-phase owns drag)', () => {
});
test('findNearestVertexPx: hit-test matrix (RCA capture-phase grab)', () => {
assert.ok(VERTEX_HIT_PX >= 12);
assert.ok(VERTEX_HIT_PX >= 16);
const verts = [
{ x: 100, y: 100 },
{ x: 200, y: 100 },