diff --git a/internal/web/static/js/openfsd/airport-editor/main.js b/internal/web/static/js/openfsd/airport-editor/main.js index 290392d..1ffba1a 100644 --- a/internal/web/static/js/openfsd/airport-editor/main.js +++ b/internal/web/static/js/openfsd/airport-editor/main.js @@ -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 diff --git a/internal/web/static/js/openfsd/airport-editor/map-layers.js b/internal/web/static/js/openfsd/airport-editor/map-layers.js index 29a5ca3..8e20df8 100644 --- a/internal/web/static/js/openfsd/airport-editor/map-layers.js +++ b/internal/web/static/js/openfsd/airport-editor/map-layers.js @@ -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) { diff --git a/internal/web/templates/airport_editor.html b/internal/web/templates/airport_editor.html index 2fdcc96..7f29a18 100644 --- a/internal/web/templates/airport_editor.html +++ b/internal/web/templates/airport_editor.html @@ -208,7 +208,11 @@ - - - +{{/* + 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. +*/}} + + + {{ end }} diff --git a/webjs/airport-editor/map-layers.test.js b/webjs/airport-editor/map-layers.test.js index e99535a..1a525fa 100644 --- a/webjs/airport-editor/map-layers.test.js +++ b/webjs/airport-editor/map-layers.test.js @@ -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 },