]> git.openstreetmap.org Git - rails.git/blob - app/assets/javascripts/index_modules/history.js
Merge pull request #7191 from CommanderStorm/fix-attribution-listener-leak
[rails.git] / app / assets / javascripts / index_modules / history.js
1 //= require ./history/changesets-layer
2 /* global ChangesetsLayer */
3
4 export default function (map) {
5   const page = {};
6
7   $("#sidebar_content")
8     .on("click", ".changeset_more a", loadMoreChangesets)
9     .on("mouseover", "[data-changeset]", function () {
10       toggleChangesetHighlight($(this).data("changeset").id, true);
11     })
12     .on("mouseout", "[data-changeset]", function () {
13       toggleChangesetHighlight($(this).data("changeset").id, false);
14     });
15
16   let inZoom = false;
17   map.on("zoomstart", () => inZoom = true);
18   map.on("zoomend", () => inZoom = false);
19
20   const changesetsLayer = new ChangesetsLayer()
21     .on("mouseover", function (e) {
22       if (inZoom) return;
23       toggleChangesetHighlight(e.layer.id, true);
24     })
25     .on("mouseout", function (e) {
26       if (inZoom) return;
27       toggleChangesetHighlight(e.layer.id, false);
28     })
29     .on("requestscrolltochangeset", function (e) {
30       const [item] = $(`#changeset_${e.id}`);
31       item?.scrollIntoView({ block: "nearest", behavior: "smooth" });
32     });
33
34   let changesetIntersectionObserver;
35
36   function disableChangesetIntersectionObserver() {
37     if (changesetIntersectionObserver) {
38       changesetIntersectionObserver.disconnect();
39       changesetIntersectionObserver = null;
40     }
41   }
42
43   function enableChangesetIntersectionObserver() {
44     disableChangesetIntersectionObserver();
45     if (!window.IntersectionObserver) return;
46
47     let keepInitialLocation = true;
48     let itemsInViewport = $();
49
50     changesetIntersectionObserver = new IntersectionObserver((entries) => {
51       let closestTargetToTop,
52           closestDistanceToTop = Infinity,
53           closestTargetToBottom,
54           closestDistanceToBottom = Infinity;
55
56       for (const entry of entries) {
57         const id = $(entry.target).data("changeset")?.id;
58
59         if (entry.isIntersecting) {
60           itemsInViewport = itemsInViewport.add(entry.target);
61           if (id) changesetsLayer.setChangesetSidebarRelativePosition(id, 0);
62           continue;
63         } else {
64           itemsInViewport = itemsInViewport.not(entry.target);
65         }
66
67         const distanceToTop = entry.rootBounds.top - entry.boundingClientRect.bottom;
68         const distanceToBottom = entry.boundingClientRect.top - entry.rootBounds.bottom;
69
70         if (distanceToTop >= 0 && distanceToTop < closestDistanceToTop) {
71           closestDistanceToTop = distanceToTop;
72           closestTargetToTop = entry.target;
73         }
74         if (distanceToBottom >= 0 && distanceToBottom <= closestDistanceToBottom) {
75           closestDistanceToBottom = distanceToBottom;
76           closestTargetToBottom = entry.target;
77         }
78       }
79
80       itemsInViewport.first().prevAll().each(function () {
81         const id = $(this).data("changeset")?.id;
82         if (id) changesetsLayer.setChangesetSidebarRelativePosition(id, 1);
83       });
84       itemsInViewport.last().nextAll().each(function () {
85         const id = $(this).data("changeset")?.id;
86         if (id) changesetsLayer.setChangesetSidebarRelativePosition(id, -1);
87       });
88
89       changesetsLayer.updateChangesetsOrder();
90
91       if (keepInitialLocation) {
92         keepInitialLocation = false;
93         return;
94       }
95
96       if (closestTargetToTop && closestDistanceToTop < closestDistanceToBottom) {
97         const id = $(closestTargetToTop).data("changeset")?.id;
98         if (id) {
99           OSM.router.replace(location.pathname + "?" + new URLSearchParams({ before: id }) + location.hash);
100         }
101       } else if (closestTargetToBottom) {
102         const id = $(closestTargetToBottom).data("changeset")?.id;
103         if (id) {
104           OSM.router.replace(location.pathname + "?" + new URLSearchParams({ after: id }) + location.hash);
105         }
106       }
107     }, { root: $("#sidebar")[0] });
108
109     $("#sidebar_content .changesets ol").children().each(function () {
110       changesetIntersectionObserver.observe(this);
111     });
112   }
113
114   function toggleChangesetHighlight(id, state) {
115     changesetsLayer.toggleChangesetHighlight(id, state);
116     $("#sidebar_content .changesets ol li").removeClass("selected");
117     if (state) {
118       $("#changeset_" + id).addClass("selected");
119     }
120   }
121
122   function displayFirstChangesets(html) {
123     $("#sidebar_content .changesets").html(html);
124
125     $("#sidebar_content .changesets ol")
126       .before($("<div class='changeset-color-hint-bar opacity-75 sticky-top changeset-above-sidebar-viewport'>"))
127       .after($("<div class='changeset-color-hint-bar opacity-75 sticky-bottom changeset-below-sidebar-viewport'>"));
128
129     if (location.pathname === "/history") {
130       setPaginationMapHashes();
131     }
132   }
133
134   function displayMoreChangesets(div, html) {
135     const sidebar = $("#sidebar")[0];
136     const previousScrollHeightMinusTop = sidebar.scrollHeight - sidebar.scrollTop;
137
138     const oldList = $("#sidebar_content .changesets ol");
139
140     div.replaceWith(html);
141
142     const prevNewList = oldList.prevAll("ol");
143     if (prevNewList.length) {
144       prevNewList.next(".changeset_more").remove();
145       prevNewList.children().prependTo(oldList);
146       prevNewList.remove();
147
148       // restore scroll position only if prepending
149       sidebar.scrollTop = sidebar.scrollHeight - previousScrollHeightMinusTop;
150     }
151
152     const nextNewList = oldList.nextAll("ol");
153     if (nextNewList.length) {
154       nextNewList.prev(".changeset_more").remove();
155       nextNewList.children().appendTo(oldList);
156       nextNewList.remove();
157     }
158
159     if (location.pathname === "/history") {
160       setPaginationMapHashes();
161     }
162   }
163
164   function setPaginationMapHashes() {
165     $("#sidebar .pagination a").each(function () {
166       $(this).prop("hash", OSM.formatHash(map));
167     });
168   }
169
170   function loadFirstChangesets() {
171     const data = new URLSearchParams();
172     const isHistory = location.pathname === "/history";
173
174     disableChangesetIntersectionObserver();
175
176     if (isHistory) {
177       setBboxFetchData(data);
178       const feedLink = $("link[type=\"application/atom+xml\"]"),
179             feedHref = feedLink.attr("href").split("?")[0];
180       feedLink.attr("href", feedHref + "?" + data);
181     }
182
183     setListFetchData(data, location);
184
185     fetch(location.pathname + "?" + data)
186       .then(response => response.text())
187       .then(function (html) {
188         displayFirstChangesets(html);
189         enableChangesetIntersectionObserver();
190
191         if (data.has("before")) {
192           const [firstItem] = $("#sidebar_content .changesets ol").children().first();
193           firstItem?.scrollIntoView();
194         } else if (data.has("after")) {
195           const [lastItem] = $("#sidebar_content .changesets ol").children().last();
196           lastItem?.scrollIntoView(false);
197         } else {
198           const [sidebar] = $("#sidebar");
199           sidebar.scrollTop = 0;
200         }
201
202         updateMap(isHistory);
203       });
204   }
205
206   function loadMoreChangesets(e) {
207     e.preventDefault();
208     e.stopPropagation();
209
210     const div = $(this).parents(".changeset_more");
211     const isHistory = location.pathname === "/history";
212
213     div.find(".pagination").addClass("invisible");
214     div.find("[hidden]").prop("hidden", false);
215
216     const data = new URLSearchParams();
217
218     if (location.pathname === "/history") {
219       setBboxFetchData(data);
220     }
221
222     const url = new URL($(this).attr("href"), location);
223     setListFetchData(data, url);
224
225     fetch(url.pathname + "?" + data)
226       .then(response => response.text())
227       .then(function (html) {
228         displayMoreChangesets(div, html);
229         enableChangesetIntersectionObserver();
230
231         updateMap(isHistory);
232       });
233   }
234
235   function setBboxFetchData(data) {
236     const crs = map.options.crs;
237     const sw = map.getBounds().getSouthWest();
238     const ne = map.getBounds().getNorthEast();
239     const swClamped = crs.unproject(crs.project(sw));
240     const neClamped = crs.unproject(crs.project(ne));
241
242     if (sw.lat >= swClamped.lat || ne.lat <= neClamped.lat || ne.lng - sw.lng < 360) {
243       data.set("bbox", map.getBounds().toBBoxString());
244     }
245   }
246
247   function setListFetchData(data, url) {
248     const params = new URLSearchParams(url.search);
249
250     data.set("list", "1");
251
252     if (params.has("before")) {
253       data.set("before", params.get("before"));
254     }
255     if (params.has("after")) {
256       data.set("after", params.get("after"));
257     }
258   }
259
260   function moveEndListener() {
261     if (location.pathname === "/history") {
262       OSM.router.replace("/history" + window.location.hash);
263       loadFirstChangesets();
264     } else {
265       $("#sidebar_content .changesets ol li").removeClass("selected");
266       changesetsLayer.updateChangesetsGeometry(map);
267     }
268   }
269
270   function zoomEndListener() {
271     $("#sidebar_content .changesets ol li").removeClass("selected");
272     changesetsLayer.updateChangesetsGeometry(map);
273   }
274
275   function updateMap(isHistory) {
276     const changesets = $("[data-changeset]").map(function (index, element) {
277       return $(element).data("changeset");
278     }).get().filter(function (changeset) {
279       return changeset.bbox;
280     });
281
282     changesetsLayer.updateChangesets(map, changesets);
283
284     if (!isHistory) {
285       const bounds = changesetsLayer.getBounds();
286       if (bounds.isValid()) map.fitBounds(bounds);
287     }
288   }
289
290   page.pushstate = page.popstate = function (path) {
291     OSM.loadSidebarContent(path, page.load);
292   };
293
294   page.load = function () {
295     map.addLayer(changesetsLayer);
296     map.on("moveend", moveEndListener);
297     map.on("zoomend", zoomEndListener);
298     loadFirstChangesets();
299   };
300
301   page.unload = function () {
302     map.removeLayer(changesetsLayer);
303     map.off("moveend", moveEndListener);
304     map.off("zoomend", zoomEndListener);
305     disableChangesetIntersectionObserver();
306   };
307
308   return page;
309 }