]> git.openstreetmap.org Git - rails.git/blob - app/assets/javascripts/index/contextmenu.js
Replace Leaflet contextmenu plugin w/ Bootstrap
[rails.git] / app / assets / javascripts / index / contextmenu.js
1 OSM.initializeContextMenu = function (map) {
2   const $contextMenu = $("#map-context-menu");
3   map.osm_contextmenu = new OSM.ContextMenu(map, $contextMenu);
4
5   const toggleMenuItem = ($element, enable) => {
6     $element.toggleClass("disabled", !enable)
7       .attr("aria-disabled", enable ? null : "true");
8   };
9
10   const updateContextMenuState = () => {
11     const zoom = map.getZoom();
12     toggleMenuItem($("#menu-action-add-note"), zoom >= 12);
13     toggleMenuItem($("#menu-action-query-features"), zoom >= 14);
14   };
15
16   const getDirectionsCoordinates = ($input) => {
17     const lat = $input.attr("data-lat");
18     const lon = $input.attr("data-lon");
19     if (lat && lon) return `${lat},${lon}`;
20     return $input.val();
21   };
22
23   const latLngFromContext = () =>
24     L.latLng($contextMenu.data("lat"), $contextMenu.data("lng"));
25
26   const croppedLatLon = () =>
27     OSM.cropLocation(latLngFromContext(), map.getZoom());
28
29   const routeWithLatLon = (path, extraParams = {}) => {
30     const [lat, lon] = croppedLatLon();
31     OSM.router.route(`${path}?` + new URLSearchParams({ lat, lon, ...extraParams }));
32   };
33
34   const contextmenuItems = [
35     {
36       id: "menu-action-directions-from",
37       icon: "bi-geo-alt",
38       text: OSM.i18n.t("javascripts.context.directions_from"),
39       callback: () => {
40         const params = new URLSearchParams({
41           from: croppedLatLon().join(","),
42           to: getDirectionsCoordinates($("#route_to"))
43         });
44         OSM.router.route(`/directions?${params}`);
45       }
46     },
47     {
48       id: "menu-action-directions-to",
49       icon: "bi-flag",
50       text: OSM.i18n.t("javascripts.context.directions_to"),
51       callback: () => {
52         const params = new URLSearchParams({
53           from: getDirectionsCoordinates($("#route_from")),
54           to: croppedLatLon().join(",")
55         });
56         OSM.router.route(`/directions?${params}`);
57       }
58     },
59     {
60       separator: true
61     },
62     {
63       id: "menu-action-add-note",
64       icon: "bi-pencil",
65       text: OSM.i18n.t("javascripts.context.add_note"),
66       callback: () => routeWithLatLon("/note/new")
67     },
68     {
69       separator: true
70     },
71     {
72       id: "menu-action-show-address",
73       icon: "bi-compass",
74       text: OSM.i18n.t("javascripts.context.show_address"),
75       callback: () => routeWithLatLon("/search", { zoom: map.getZoom() })
76     },
77     {
78       id: "menu-action-query-features",
79       icon: "bi-question-circle",
80       text: OSM.i18n.t("javascripts.context.query_features"),
81       callback: () => routeWithLatLon("/query")
82     },
83     {
84       id: "menu-action-centre-map",
85       icon: "bi-crosshair",
86       text: OSM.i18n.t("javascripts.context.centre_map"),
87       callback: () => map.panTo(latLngFromContext())
88     }
89   ];
90
91   // Event bindings
92   map.on("contextmenu", function (e) {
93     map.osm_contextmenu.show(e, contextmenuItems);
94     updateContextMenuState();
95   });
96
97   map.on("show-contextmenu", function (data) {
98     map.osm_contextmenu.show(data.event, data.items);
99   });
100
101   map.on("zoomend", updateContextMenuState);
102 };
103
104 class ContextMenu {
105   constructor(map, $element) {
106     this._map = map;
107     this._$element = $element;
108     this._popperInstance = null;
109
110     this._map.on("click movestart", this.hide, this);
111     $(document).on("click", (e) => {
112       if (!$(e.target).closest(this._$element).length) {
113         this.hide();
114       }
115     });
116   }
117
118   show(e, items) {
119     e.originalEvent.preventDefault();
120     e.originalEvent.stopPropagation();
121
122     this._render(items);
123     this._$element.removeClass("d-none");
124     this._updatePopper(e);
125
126     this._$element.data("lat", e.latlng.lat);
127     this._$element.data("lng", e.latlng.lng);
128   }
129
130   hide() {
131     this._$element.addClass("d-none");
132     if (this._popperInstance) {
133       this._popperInstance.destroy();
134       this._popperInstance = null;
135     }
136   }
137
138   _updatePopper(e) {
139     const getVirtualReference = (x, y) => ({
140       getBoundingClientRect: () => ({
141         width: 0, height: 0, top: y, left: x, right: x, bottom: y
142       })
143     });
144
145     if (this._popperInstance) {
146       this._popperInstance.destroy();
147       this._popperInstance = null;
148     }
149
150     const virtualReference = getVirtualReference(
151       e.originalEvent.clientX,
152       e.originalEvent.clientY
153     );
154
155     this._popperInstance = Popper.createPopper(virtualReference, this._$element.find(".dropdown-menu")[0], {
156       placement: "bottom-start",
157       strategy: "absolute",
158       modifiers: [
159         {
160           name: "offset",
161           options: {
162             offset: [0, 0] // no offset, exactly aligned to placement corner
163           }
164         },
165         {
166           name: "preventOverflow",
167           options: { boundary: document.getElementById("map") }
168         },
169         {
170           name: "flip",
171           options: {
172             fallbackPlacements: ["top-start", "bottom-end", "top-end"]
173           }
174         }
175       ]
176     });
177   }
178
179   _render(items) {
180     const $menuList = $("<ul>").addClass("dropdown-menu show shadow cm_dropdown_menu");
181
182     items.forEach((item) => {
183       const $menuItem = item.separator ?
184         this._createSeparator() :
185         this._createMenuItem(item);
186       $menuList.append($menuItem);
187     });
188
189     this._$element.empty().append($menuList);
190   }
191
192   _createMenuItem(item) {
193     const $icon = $("<i>").addClass(`bi ${item.icon}`);
194     const $label = $("<span>").text(item.text);
195
196     const $link = $("<a>")
197       .addClass("dropdown-item d-flex align-items-center gap-3")
198       .attr({ href: "#", id: item.id })
199       .append($icon, $label)
200       .on("click", (e) => {
201         e.preventDefault();
202         item.callback?.();
203         this.hide();
204       });
205
206     return $("<li>").append($link);
207   }
208
209   _createSeparator() {
210     return $("<li>").append($("<hr>").addClass("dropdown-divider"));
211   }
212 }
213
214 OSM.ContextMenu = ContextMenu;