From 4ad0778495e611e87890e48532c6e6e0d8ee5af4 Mon Sep 17 00:00:00 2001 From: mmd-osm Date: Tue, 21 Oct 2025 20:01:58 +0200 Subject: [PATCH] Replace Leaflet contextmenu plugin w/ Bootstrap --- app/assets/javascripts/index/contextmenu.js | 256 +++++++++++++----- .../index/history-changesets-layer.js | 21 +- app/assets/stylesheets/common.scss | 32 +++ app/views/layouts/map.html.erb | 2 + config/eslint.config.mjs | 1 + .../initializers/content_security_policy.rb | 2 +- package.json | 1 + yarn.lock | 5 + 8 files changed, 246 insertions(+), 74 deletions(-) diff --git a/app/assets/javascripts/index/contextmenu.js b/app/assets/javascripts/index/contextmenu.js index dcdfc9e44..83a04f55a 100644 --- a/app/assets/javascripts/index/contextmenu.js +++ b/app/assets/javascripts/index/contextmenu.js @@ -1,80 +1,214 @@ OSM.initializeContextMenu = function (map) { - map.contextmenu.addItem({ - text: OSM.i18n.t("javascripts.context.directions_from"), - callback: function directionsFromHere(e) { - const latlng = OSM.cropLocation(e.latlng, map.getZoom()); - - OSM.router.route("/directions?" + new URLSearchParams({ - from: latlng.join(","), - to: getDirectionsEndpointCoordinatesFromInput($("#route_to")) - })); - } - }); + const $contextMenu = $("#map-context-menu"); + map.osm_contextmenu = new OSM.ContextMenu(map, $contextMenu); - map.contextmenu.addItem({ - text: OSM.i18n.t("javascripts.context.directions_to"), - callback: function directionsToHere(e) { - const latlng = OSM.cropLocation(e.latlng, map.getZoom()); + const toggleMenuItem = ($element, enable) => { + $element.toggleClass("disabled", !enable) + .attr("aria-disabled", enable ? null : "true"); + }; - OSM.router.route("/directions?" + new URLSearchParams({ - from: getDirectionsEndpointCoordinatesFromInput($("#route_from")), - to: latlng.join(",") - })); - } - }); + const updateContextMenuState = () => { + const zoom = map.getZoom(); + toggleMenuItem($("#menu-action-add-note"), zoom >= 12); + toggleMenuItem($("#menu-action-query-features"), zoom >= 14); + }; - map.contextmenu.addItem({ - text: OSM.i18n.t("javascripts.context.add_note"), - callback: function addNoteHere(e) { - const [lat, lon] = OSM.cropLocation(e.latlng, map.getZoom()); + const getDirectionsCoordinates = ($input) => { + const lat = $input.attr("data-lat"); + const lon = $input.attr("data-lon"); + if (lat && lon) return `${lat},${lon}`; + return $input.val(); + }; - OSM.router.route("/note/new?" + new URLSearchParams({ lat, lon })); - } - }); + const latLngFromContext = () => + L.latLng($contextMenu.data("lat"), $contextMenu.data("lng")); + + const croppedLatLon = () => + OSM.cropLocation(latLngFromContext(), map.getZoom()); - map.contextmenu.addItem({ - text: OSM.i18n.t("javascripts.context.show_address"), - callback: function describeLocation(e) { - const zoom = map.getZoom(); - const [lat, lon] = OSM.cropLocation(e.latlng, zoom); + const routeWithLatLon = (path, extraParams = {}) => { + const [lat, lon] = croppedLatLon(); + OSM.router.route(`${path}?` + new URLSearchParams({ lat, lon, ...extraParams })); + }; - OSM.router.route("/search?" + new URLSearchParams({ lat, lon, zoom })); + const contextmenuItems = [ + { + id: "menu-action-directions-from", + icon: "bi-geo-alt", + text: OSM.i18n.t("javascripts.context.directions_from"), + callback: () => { + const params = new URLSearchParams({ + from: croppedLatLon().join(","), + to: getDirectionsCoordinates($("#route_to")) + }); + OSM.router.route(`/directions?${params}`); + } + }, + { + id: "menu-action-directions-to", + icon: "bi-flag", + text: OSM.i18n.t("javascripts.context.directions_to"), + callback: () => { + const params = new URLSearchParams({ + from: getDirectionsCoordinates($("#route_from")), + to: croppedLatLon().join(",") + }); + OSM.router.route(`/directions?${params}`); + } + }, + { + separator: true + }, + { + id: "menu-action-add-note", + icon: "bi-pencil", + text: OSM.i18n.t("javascripts.context.add_note"), + callback: () => routeWithLatLon("/note/new") + }, + { + separator: true + }, + { + id: "menu-action-show-address", + icon: "bi-compass", + text: OSM.i18n.t("javascripts.context.show_address"), + callback: () => routeWithLatLon("/search", { zoom: map.getZoom() }) + }, + { + id: "menu-action-query-features", + icon: "bi-question-circle", + text: OSM.i18n.t("javascripts.context.query_features"), + callback: () => routeWithLatLon("/query") + }, + { + id: "menu-action-centre-map", + icon: "bi-crosshair", + text: OSM.i18n.t("javascripts.context.centre_map"), + callback: () => map.panTo(latLngFromContext()) } - }); + ]; - map.contextmenu.addItem({ - text: OSM.i18n.t("javascripts.context.query_features"), - callback: function queryFeatures(e) { - const [lat, lon] = OSM.cropLocation(e.latlng, map.getZoom()); + // Event bindings + map.on("contextmenu", function (e) { + map.osm_contextmenu.show(e, contextmenuItems); + updateContextMenuState(); + }); - OSM.router.route("/query?" + new URLSearchParams({ lat, lon })); - } + map.on("show-contextmenu", function (data) { + map.osm_contextmenu.show(data.event, data.items); }); - map.contextmenu.addItem({ - text: OSM.i18n.t("javascripts.context.centre_map"), - callback: function centreMap(e) { - map.panTo(e.latlng); + map.on("zoomend", updateContextMenuState); +}; + +class ContextMenu { + constructor(map, $element) { + this._map = map; + this._$element = $element; + this._popperInstance = null; + + this._map.on("click movestart", this.hide, this); + $(document).on("click", (e) => { + if (!$(e.target).closest(this._$element).length) { + this.hide(); + } + }); + } + + show(e, items) { + e.originalEvent.preventDefault(); + e.originalEvent.stopPropagation(); + + this._render(items); + this._$element.removeClass("d-none"); + this._updatePopper(e); + + this._$element.data("lat", e.latlng.lat); + this._$element.data("lng", e.latlng.lng); + } + + hide() { + this._$element.addClass("d-none"); + if (this._popperInstance) { + this._popperInstance.destroy(); + this._popperInstance = null; } - }); + } - map.on("mousedown", function (e) { - if (e.originalEvent.shiftKey) map.contextmenu.disable(); - else map.contextmenu.enable(); - }); + _updatePopper(e) { + const getVirtualReference = (x, y) => ({ + getBoundingClientRect: () => ({ + width: 0, height: 0, top: y, left: x, right: x, bottom: y + }) + }); - function getDirectionsEndpointCoordinatesFromInput(input) { - if (input.attr("data-lat") && input.attr("data-lon")) { - return input.attr("data-lat") + "," + input.attr("data-lon"); + if (this._popperInstance) { + this._popperInstance.destroy(); + this._popperInstance = null; } - return $(input).val(); + + const virtualReference = getVirtualReference( + e.originalEvent.clientX, + e.originalEvent.clientY + ); + + this._popperInstance = Popper.createPopper(virtualReference, this._$element.find(".dropdown-menu")[0], { + placement: "bottom-start", + strategy: "absolute", + modifiers: [ + { + name: "offset", + options: { + offset: [0, 0] // no offset, exactly aligned to placement corner + } + }, + { + name: "preventOverflow", + options: { boundary: document.getElementById("map") } + }, + { + name: "flip", + options: { + fallbackPlacements: ["top-start", "bottom-end", "top-end"] + } + } + ] + }); } - const updateMenu = function updateMenu() { - map.contextmenu.setDisabled(2, map.getZoom() < 12); - map.contextmenu.setDisabled(4, map.getZoom() < 14); - }; + _render(items) { + const $menuList = $("