mirror of
https://github.com/renorris/openfsd
synced 2026-08-13 04:55:42 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user