nakarte

Source code of https://map.sikmir.ru (fork)
git clone git://git.sikmir.ru/nakarte
Log | Files | Refs | LICENSE

commit 7b9847743626f49b48fee675edd62566b1cc8d24
parent 54480a88e58dfbab6b989b83fb4c2600dca16d9b
Author: Sergej Orlov <wladimirych@gmail.com>
Date:   Tue, 14 Jan 2025 22:40:24 +0100

Store tracks and hash state in IndexedDB, add control for loading saved states

fixes: #228

Diffstat:
Meslint_rules/imports_webapp.js | 1-
Mpackage.json | 1+
Msrc/App.js | 53+++++++++++++++++++++++------------------------------
Asrc/lib/leaflet.control.sessions/close.svg | 6++++++
Asrc/lib/leaflet.control.sessions/index.js | 365+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.control.sessions/style.css | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/leaflet.control.track-list/track-list.js | 37++++++++++++++++++++++++++++++++-----
Dsrc/lib/leaflet.control.track-list/track-list.localstorage.js | 91-------------------------------------------------------------------------------
Msrc/lib/leaflet.hashState/hashState.js | 6++++--
Asrc/lib/session-state/index.js | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Myarn.lock | 5+++++
11 files changed, 723 insertions(+), 129 deletions(-)

diff --git a/eslint_rules/imports_webapp.js b/eslint_rules/imports_webapp.js @@ -14,7 +14,6 @@ const filesWithSideEffects = [ 'src/lib/leaflet.control.printPages/control.js', 'src/lib/leaflet.control.track-list/control-ruler.js', 'src/lib/leaflet.control.track-list/track-list.hash-state.js', - 'src/lib/leaflet.control.track-list/track-list.localstorage.js', 'src/lib/leaflet.hashState/Leaflet.Control.Layers.js', 'src/lib/leaflet.hashState/Leaflet.Map.js', 'src/lib/leaflet.hashState/leaflet.hashState.js', diff --git a/package.json b/package.json @@ -80,6 +80,7 @@ "browser-filesaver": "^1.1.1", "core-js": "^3.30.1", "escape-html": "^1.0.3", + "idb": "^8.0.1", "image-promise": "^7.0.1", "knockout": "^3.4.0", "leaflet": "1.0.3", diff --git a/src/App.js b/src/App.js @@ -16,7 +16,6 @@ import '~/lib/leaflet.control.panoramas'; import '~/lib/leaflet.control.track-list/track-list'; import '~/lib/leaflet.control.track-list/control-ruler'; import '~/lib/leaflet.control.track-list/track-list.hash-state'; -import '~/lib/leaflet.control.track-list/track-list.localstorage'; import enableLayersControlAdaptiveHeight from '~/lib/leaflet.control.layers.adaptive-height'; import enableLayersMinimize from '~/lib/leaflet.control.layers.minimize'; import enableLayersConfig from '~/lib/leaflet.control.layers.configure'; @@ -26,6 +25,7 @@ import '~/lib/leaflet.control.layers.events'; import '~/lib/leaflet.control.jnx'; import '~/lib/leaflet.control.jnx/hash-state'; import '~/lib/leaflet.control.azimuth'; +import {SessionsControl} from '~/lib/leaflet.control.sessions'; import {hashState, bindHashStateReadOnly} from '~/lib/leaflet.hashState/hashState'; import {LocateControl} from '~/lib/leaflet.control.locate'; import {notify} from '~/lib/notifications'; @@ -46,6 +46,11 @@ const minimizeStateAuto = 0; const minimizeStateMinimized = 1; const minimizeStateExpanded = 2; +function isInIframe() { + // Check if the window is not the top window + return window.self !== window.top; +} + function setUp() { // eslint-disable-line complexity const startInfo = { href: window.location.href, @@ -115,6 +120,11 @@ function setUp() { // eslint-disable-line complexity stackHorizontally: true }).addTo(map); + let sessionsControl; + if (!isInIframe()) { + sessionsControl = new SessionsControl(tracklist, {position: 'topleft'}).addTo(map); + } + new ExternalMaps({position: 'topleft'}).addTo(map); new L.Control.TrackList.Ruler(tracklist).addTo(map); @@ -188,9 +198,6 @@ function setUp() { // eslint-disable-line complexity /* controls bottom-right corner */ - function trackNames() { - return tracklist.tracks().map((track) => track.name()); - } tracklist.addTo(map); const tracksHashParams = tracklist.hashParams(); @@ -201,10 +208,19 @@ function setUp() { // eslint-disable-line complexity break; } } - if (!hasTrackParamsInHash) { - tracklist.loadTracksFromStorage(); + + if (sessionsControl) { + (async() => { + await sessionsControl.loadSession(); + await sessionsControl.consumeSessionFromHash(); + if (await sessionsControl.importOldSessions() && !hasTrackParamsInHash) { + notify( + 'If some tracks disappeared from the tracks list, ' + + 'you can find them in the new list of recent sessions in the upper left corner.' + ); + } + })(); } - startInfo.tracksAfterLoadFromStorage = trackNames(); if (hashState.hasKey('autoprofile') && hasTrackParamsInHash) { tracklist.once('loadedTracksFromParam', () => { @@ -226,7 +242,6 @@ function setUp() { // eslint-disable-line complexity for (let param of tracksHashParams) { bindHashStateReadOnly(param, tracklist.loadTrackFromParam.bind(tracklist, param)); } - startInfo.tracksAfterLoadFromHash = trackNames(); /* set map position */ @@ -260,28 +275,6 @@ function setUp() { // eslint-disable-line complexity raiseControlsOnFocus(map); - /* save state at unload */ - - L.DomEvent.on(window, 'beforeunload', () => { - logging.logEvent('saveTracksToStorage begin', { - localStorageKeys: Object.keys(safeLocalStorage), - trackNames: trackNames(), - }); - const t = Date.now(); - let localStorageKeys; - try { - tracklist.saveTracksToStorage(); - localStorageKeys = Object.keys(safeLocalStorage); - } catch (e) { - logging.logEvent('saveTracksToStorage failed', {error: e}); - return; - } - logging.logEvent('saveTracksToStorage done', { - time: Date.now() - t, - localStorageKeys - }); - }); - /* track list and azimuth measure interaction */ tracklist.on('startedit', () => azimuthControl.disableControl()); diff --git a/src/lib/leaflet.control.sessions/close.svg b/src/lib/leaflet.control.sessions/close.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"> + <g stroke="#555" stroke-width="2"> + <line x1="0" y1="0" x2="10" y2="10"/> + <line x1="0" y1="10" x2="10" y2="0"/> + </g> +</svg> diff --git a/src/lib/leaflet.control.sessions/index.js b/src/lib/leaflet.control.sessions/index.js @@ -0,0 +1,365 @@ +import md5 from 'blueimp-md5'; +import ko from 'knockout'; +import L from 'leaflet'; + +import {makeButton} from '~/lib/leaflet.control.commons'; +import {parseNktkSequence} from '~/lib/leaflet.control.track-list/lib/parsers/nktk'; +import {bindHashStateReadOnly} from '~/lib/leaflet.hashState/hashState'; +import {notify} from '~/lib/notifications'; +import { + activeSessionsMonitor, + EVENT_ACTIVE_SESSIONS_CHANGED, + EVENT_STORED_SESSIONS_CHANGED, + session, + sessionRepository, +} from '~/lib/session-state'; + +import './style.css'; + +function formatDateTime(ts) { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const date = new Date(ts); + + const month = months[date.getMonth()]; + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + const seconds = date.getSeconds().toString().padStart(2, '0'); + return `${month} ${date.getDate()} ${date.getFullYear()} ${hours}:${minutes}:${seconds}`; +} + +function temporaryDisableAfterInvocation(f, delay) { + let lastCalledAt = null; + + function wrapper(...args) { + if (lastCalledAt === null || lastCalledAt + delay < Date.now()) { + try { + f(...args); + } finally { + lastCalledAt = Date.now(); + } + } + } + + return wrapper; +} + +const SessionsControl = L.Control.extend({ + includes: L.Mixin.Events, + + initialize: function (trackListControl, options) { + L.Control.prototype.initialize.call(this, options); + this.trackListControl = trackListControl; + this.sessionListWindowVisible = false; + this.loadingState = false; + this.channel = new BroadcastChannel('session-control'); + this.channel.addEventListener('message', (e) => this.onChannelMessage(e)); + this.saveCurrentState = L.Util.throttle(this.saveCurrentStateImmediate, 1000, this); + this.trackListControl.on('trackschanged', () => this.onCurrentStateChange()); + window.addEventListener('hashchange', () => this.onCurrentStateChange()); + window.addEventListener('unload', () => this.saveCurrentStateImmediate()); + window.addEventListener('pagehide', () => this.saveCurrentStateImmediate()); + this.canSwitchFocus = !L.Browser.mobile; + + this.sessionListWindowModel = { + activeSessions: ko.observableArray([]), + inactiveSessions: ko.observableArray([]), + visible: ko.observable(false), + formatDateTime, + maxTrackLines: 4, + requestSwitchFocus: (sessionData) => this.requestSwitchFocus(sessionData.sessionId), + openStoredSession: temporaryDisableAfterInvocation( + (sessionData) => this.openStoredSession(sessionData.sessionId), + 200 + ), + closeWindow: () => this.hideSessionListWindow(), + canSwitchFocus: this.canSwitchFocus, + }; + }, + + setupSessionListWindow: function () { + const layout = ` + <div data-bind="visible: visible" class="leaflet-control-session-list-wrapper"> + <div class="leaflet-control-session-list-window"> + <div class="leaflet-control-session-list-window-header"> + <div class="button-close" data-bind="click: closeWindow"></div> + </div> + <div class="leaflet-control-session-list-scrollbox"> + <!-- ko if: activeSessions().length --> + <div class="leaflet-control-session-list-header"> + Active sessions with tracks in other tabs + </div> + <!-- ko if: canSwitchFocus --> + <div class="leaflet-control-session-list-header-info">Click to switch tab</div> + <!-- /ko --> + <!-- /ko --> + <!-- ko foreach: activeSessions --> + <div + class="leaflet-control-session-list-item-active" + data-bind=" + attr: {title: data.trackNames.join('\\n')}, + click: $root.requestSwitchFocus, + css: {'click-disabled': !$root.canSwitchFocus}"> + <!-- ko foreach: data.trackNames.length <= $root.maxTrackLines + ? data.trackNames + : data.trackNames.slice(0, $root.maxTrackLines - 1) + --> + <div class="leaflet-control-session-list-item-track" data-bind="text: $data"></div> + <!-- /ko --> + <!-- ko if: data.trackNames.length > $root.maxTrackLines --> + <div> + &hellip;total <span data-bind="text: data.trackNames.length"></span> + tracks&hellip; + </div> + <!-- /ko --> + </div> + <!-- /ko --> + + <!-- ko if: inactiveSessions().length --> + <div class="leaflet-control-session-list-header">Recently opened sessions with tracks</div> + <div class="leaflet-control-session-list-header-info">Click to open in new tab</div> + <!-- /ko --> + + <!-- ko foreach: inactiveSessions --> + <div + class="leaflet-control-session-list-item-inactive" + data-bind="attr: {title: data.trackNames.join('\\n')}, click: $root.openStoredSession" + > + <div class="leaflet-control-session-list-item-date"> + Last used at <span data-bind="text: $root.formatDateTime($data.mtime)"></span> + </div> + <!-- ko foreach: data.trackNames.length <= $root.maxTrackLines + ? data.trackNames + : data.trackNames.slice(0, $root.maxTrackLines - 1) + --> + <div class="leaflet-control-session-list-item-track" data-bind="text: $data"></div> + <!-- /ko --> + <!-- ko if: data.trackNames.length > $root.maxTrackLines --> + <div> + &hellip;total <span data-bind="text: data.trackNames.length"></span> + tracks&hellip; + </div> + <!-- /ko --> + + </div> + <!-- /ko --> + <!-- ko if: !activeSessions().length && !inactiveSessions().length --> + <div class="leaflet-control-session-list-header">No recent sessions with tracks</div> + <!-- /ko --> + </div> + </div> + </div> + `; + + const container = L.DomUtil.create('div'); + container.innerHTML = layout; + const sessionListWindow = container.querySelector('.leaflet-control-session-list-window'); + L.DomEvent.disableClickPropagation(sessionListWindow); + L.DomEvent.disableScrollPropagation(sessionListWindow); + ko.applyBindings(this.sessionListWindowModel, container); + this._map._controlContainer.appendChild(container); + }, + + onAdd: function () { + const {container, link} = makeButton(null, 'Recent sessions'); + L.DomEvent.on(link, 'click', () => this.toggleSessionListsVisible()); + this.setupSessionListWindow(); + return container; + }, + + onChannelMessage: function (e) { + const messageData = e.data; + if (messageData.message === 'focus' && messageData.sessionId === session.sessionId) { + this.switchFocus(); + } + }, + + onActiveSessionsChange: function () { + this.updateSessionLists(); + }, + + onStoredSessionsChange: function () { + this.updateSessionLists(); + }, + + onCurrentStateChange: function () { + if (!this.loadingState) { + this.saveCurrentState(); + } + }, + + toggleSessionListsVisible: function () { + if (this.sessionListWindowVisible) { + this.hideSessionListWindow(); + } else { + this.showSessionListWindow(); + } + }, + + showSessionListWindow: function () { + if (this.sessionListWindowVisible) { + return; + } + this.sessionListWindowVisible = true; + this.updateSessionLists(); + this.sessionListWindowModel.visible(true); + this.setupEventsForSessionListWindow(true); + activeSessionsMonitor.startMonitor(); + }, + + hideSessionListWindow: function () { + if (!this.sessionListWindowVisible) { + return; + } + this.sessionListWindowVisible = false; + this.sessionListWindowModel.visible(false); + activeSessionsMonitor.stopMonitor(); + this.setupEventsForSessionListWindow(false); + }, + + setupEventsForSessionListWindow: function (on) { + L.DomEvent[on ? 'on' : 'off']( + window, + { + keydown: this.onKeyDown, + [EVENT_ACTIVE_SESSIONS_CHANGED]: this.onActiveSessionsChange, + [EVENT_STORED_SESSIONS_CHANGED]: this.onStoredSessionsChange, + }, + this + ); + }, + + onKeyDown: function (e) { + if (e.keyCode === 27) { + this.hideSessionListWindow(); + } + }, + + requestSwitchFocus: async function (sessionId) { + if (!this.canSwitchFocus) { + return; + } + if (!window.Notification) { + notify('Can not switch to another window, your browser does not support notifications.'); + return; + } + if (window.Notification.permission !== 'granted') { + notify('Please allow notifications to be able to switch to other sessions.'); + await Notification.requestPermission(); + } + this.channel.postMessage({message: 'focus', sessionId}); + }, + + openStoredSession: async function (sessionId) { + // Opening window before await-ing for promise helps to avoid new window being blocked in Firefox + const newWindow = window.open('', '_blank'); + const sessionData = await sessionRepository.getSessionState(sessionId); + if (!sessionData) { + newWindow.close(); + notify("Saved session was lost. Maybe it's a bug."); + return; + } + const {origin, pathname} = window.location; // eslint-disable-line no-shadow + newWindow.location = `${origin}${pathname}${sessionData.hash}&sid=${sessionId}`; + newWindow.focus(); + }, + + switchFocus: function () { + const notification = new Notification('Switch nakarte.me window', { + body: 'Click here to switch nakarte.me window.', + }); + notification.addEventListener('click', () => { + parent.focus(); + window.focus(); + notification.close(); + }); + }, + + saveCurrentStateImmediate: function () { + const tracks = this.trackListControl.tracks(); + if (!tracks.length) { + session.clearState(); + return; + } + const {hash} = window.location; + const trackNames = tracks.map((track) => track.name()); + const tracksSerialized = this.trackListControl.serializeTracks(tracks); + session.saveState({hash, tracks: tracksSerialized, trackNames}); + }, + + loadSession: async function () { + const sessionSavedTracks = (await session.loadState())?.tracks; + if (sessionSavedTracks) { + this.loadingState = true; + try { + this.trackListControl.loadTracksFromString(sessionSavedTracks, true); + } finally { + this.loadingState = false; + } + // if session state is not found, it can be caused by + // 1. tab was restored using Ctrl-Shift-T but session was pruned (very unlikely) + // 2. session was opened using session list control in another tab + // 3. there were no tracks in this session (most likely). + // If we want to show notification for case 2. then we will need to store sessions without + // tracks as well. + } + this.saveCurrentStateImmediate(); + }, + + consumeSessionFromHash: function () { + bindHashStateReadOnly( + 'sid', + async (params) => { + if (!params) { + return; + } + const sessionId = params[0]; + const sessionState = await sessionRepository.getSessionState(sessionId); + if (!sessionState) { + notify("Saved session was lost. Maybe it's a bug."); + return; + } + this.loadingState = true; + try { + this.trackListControl.loadTracksFromString(sessionState.tracks, true); + } finally { + this.loadingState = false; + } + this.saveCurrentStateImmediate(); + await sessionRepository.clearSessionState(sessionId); + }, + true + ); + }, + + importOldSessions: async function () { + const oldDataPrefix = '#nktk='; + let imported = false; + for (const [key, value] of Object.entries(window.localStorage)) { + const m = key.match(/^trackList_\d+$/u); + if (m && value.startsWith(oldDataPrefix)) { + const tracksSerialized = value.slice(oldDataPrefix.length); + const geodata = parseNktkSequence(tracksSerialized); + const trackNames = geodata.map((track) => track.name); + const sessionId = 'imported_' + md5(tracksSerialized); + await sessionRepository.setSessionState(sessionId, {hash: '#', tracks: tracksSerialized, trackNames}); + delete window.localStorage[key]; + imported = true; + } + } + return imported; + }, + + updateSessionLists: async function () { + const storedSessions = (await sessionRepository.listSessionStates()).filter( + (sess) => sess.sessionId !== session.sessionId + ); + const activeSessionIds = activeSessionsMonitor.getActiveSessions(); + const activeSessions = storedSessions.filter((sess) => activeSessionIds.includes(sess.sessionId)); + const inactiveSessions = storedSessions.filter((sess) => !activeSessionIds.includes(sess.sessionId)); + inactiveSessions.sort((sess1, sess2) => sess2.mtime - sess1.mtime); + // no need to sort active sessions, IndexedDb returns them sorted by sessionId which is fine. + this.sessionListWindowModel.activeSessions(activeSessions); + this.sessionListWindowModel.inactiveSessions(inactiveSessions); + }, +}); +export {SessionsControl}; diff --git a/src/lib/leaflet.control.sessions/style.css b/src/lib/leaflet.control.sessions/style.css @@ -0,0 +1,92 @@ +.leaflet-control-session-list-wrapper { + position: absolute; + top: 25px; + bottom: 25px; + max-width: 90%; + left: 50%; +} + +.leaflet-control-session-list-window { + position: relative; + transform: translate(-50%, 0); + min-width: 310px; + max-width: 1000px; + height: 100%; + + overflow: hidden; + + box-sizing: border-box; + padding: 6px 0 4px 0; + + background-color: white; + border-radius: 5px; + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4); + + cursor: default; + z-index: 3000; +} + +.leaflet-control-session-list-scrollbox { + overflow: auto; + max-height: calc(100% - 10px); + padding: 0 6px 0 6px; + margin-top: 4px; +} + +.leaflet-control-session-list-item-active, +.leaflet-control-session-list-item-inactive { + padding: 4px; + margin-bottom: 2px; + border-radius: 4px; + cursor: pointer; +} + +.leaflet-control-session-list-item-active { + background-color: #f7f7e7; +} + +.click-disabled { + cursor: default; + -webkit-tap-highlight-color: transparent; +} + +.leaflet-control-session-list-item-inactive { + background-color: #f0f0f0; +} + +.leaflet-control-session-list-header { + font-weight: bold; + font-size: 14px; + text-align: center; +} + +.leaflet-control-session-list-header-info { + font-size: 11px; + text-align: center; +} + +.leaflet-control-session-list-item-date { + color: #666666; + font-size: 11px; + font-weight: bold; +} + +.leaflet-control-session-list-item-track { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.leaflet-control-session-list-window-header { + height: 10px; + clear: both; + margin-right: 4px; +} + +.leaflet-control-session-list-window .button-close { + width: 10px; + height: 10px; + background-image: url("./close.svg"); + cursor: pointer; + float: right; +} diff --git a/src/lib/leaflet.control.track-list/track-list.js b/src/lib/leaflet.control.track-list/track-list.js @@ -26,6 +26,7 @@ import md5 from 'blueimp-md5'; import {wrapLatLngToTarget, wrapLatLngBoundsToTarget} from '~/lib/leaflet.fixes/fixWorldCopyJump'; import {splitLinesAt180Meridian} from "./lib/meridian180"; import {ElevationProvider} from '~/lib/elevations'; +import {parseNktkSequence} from './lib/parsers/nktk'; const TRACKLIST_TRACK_COLORS = ['#77f', '#f95', '#0ff', '#f77', '#f7f', '#ee5']; @@ -359,7 +360,7 @@ L.Control.TrackList = L.Control.extend({ }); }, - addTracksFromGeodataArray: function(geodata_array) { + addTracksFromGeodataArray: function(geodata_array, allowEmpty = false) { let hasData = false; var messages = []; if (geodata_array.length === 0) { @@ -369,7 +370,7 @@ L.Control.TrackList = L.Control.extend({ var data_empty = !((geodata.tracks && geodata.tracks.length) || (geodata.points && geodata.points.length)); - if (!data_empty) { + if (!data_empty || allowEmpty) { if (geodata.tracks) { geodata.tracks = geodata.tracks.map(function(line) { line = unwrapLatLngsCrossing180Meridian(line); @@ -398,7 +399,7 @@ L.Control.TrackList = L.Control.extend({ } else { message += ', loaded data can be invalid or incomplete'; } - } else if (data_empty) { + } else if (data_empty && !allowEmpty) { message = 'No data could be loaded from file "{name}". ' + 'File is empty or contains only unsupported data.'; @@ -427,6 +428,7 @@ L.Control.TrackList = L.Control.extend({ if (track.visible()) { this._markerLayer.updateMarkers(markers); } + this.notifyTracksChanged(); }, onTrackVisibilityChanged: function(track) { @@ -441,6 +443,7 @@ L.Control.TrackList = L.Control.extend({ this._markerLayer.removeMarkers(track.markers); } this.updateTrackHighlight(); + this.notifyTracksChanged(); }, onTrackSegmentNodesChanged: function(track, segment) { @@ -448,6 +451,7 @@ L.Control.TrackList = L.Control.extend({ this.trackAddingSegment(null); } this.recalculateTrackLength(track); + this.notifyTracksChanged(); }, recalculateTrackLength: function(track) { @@ -473,6 +477,7 @@ L.Control.TrackList = L.Control.extend({ var visible = track.measureTicksShown(), lines = this.getTrackPolylines(track); lines.forEach((line) => line.setMeasureTicksVisible(visible)); + this.notifyTracksChanged(); }, switchMeasureTicksVisibility: function(track) { @@ -607,12 +612,16 @@ L.Control.TrackList = L.Control.extend({ ); }, + serializeTracks: function(tracks) { + return tracks.map((track) => this.trackToString(track)).join('/'); + }, + copyTracksLinkToClipboard: function(tracks, mouseEvent) { if (!tracks.length) { notify('No tracks to copy'); return; } - let serialized = tracks.map((track) => this.trackToString(track)).join('/'); + let serialized = this.serializeTracks(tracks); const hashDigest = md5(serialized, null, true); const key = btoa(hashDigest).replace(/\//ug, '_').replace(/\+/ug, '-').replace(/=/ug, ''); const url = getLinkToShare(this.options.keysToExcludeOnCopyLink, {nktl: key}); @@ -699,6 +708,7 @@ L.Control.TrackList = L.Control.extend({ var newName = query('Enter new name', track.name()); if (newName && newName.length) { track.name(newName); + this.notifyTracksChanged(); } }, @@ -777,6 +787,7 @@ L.Control.TrackList = L.Control.extend({ const newLatLng = e.latlng.wrap(); this._markerLayer.setMarkerPosition(marker, newLatLng); this.stopPlacingPoint(); + this.notifyTracksChanged(); }, getNewPointName: function(track) { @@ -799,6 +810,7 @@ L.Control.TrackList = L.Control.extend({ const newLatLng = e.latlng.wrap(); const marker = this.addPoint(parentTrack, {name: name, lat: newLatLng.lat, lng: newLatLng.lng}); this._markerLayer.addMarker(marker); + this.notifyTracksChanged(); // we need to show prompt after marker is dispalyed; // grid layer is updated in setTimout(..., 0)after adding marker // it is better to do it on 'load' event but when it is fired marker is not yet displayed @@ -883,6 +895,7 @@ L.Control.TrackList = L.Control.extend({ // polyline.on('editingend', this.setTrackMeasureTicksVisibility.bind(this, track)); track.feature.addLayer(polyline); this.recalculateTrackLength(track); + this.notifyTracksChanged(); return polyline; }, @@ -1175,6 +1188,7 @@ L.Control.TrackList = L.Control.extend({ const track = trackSegment._parentTrack; track.feature.removeLayer(trackSegment); this.recalculateTrackLength(track); + this.notifyTracksChanged(); }, newTrackFromSegment: function(trackSegment) { @@ -1222,6 +1236,7 @@ L.Control.TrackList = L.Control.extend({ this.onTrackVisibilityChanged(track); this.attachColorSelector(track); this.attachActionsMenu(track); + this.notifyTracksChanged(); return track; }, @@ -1319,6 +1334,7 @@ L.Control.TrackList = L.Control.extend({ const markers = marker._parentTrack.markers; const i = markers.indexOf(marker); markers.splice(i, 1); + this.notifyTracksChanged(); }, renamePoint: function(marker) { @@ -1327,12 +1343,14 @@ L.Control.TrackList = L.Control.extend({ if (newLabel !== null) { this.setMarkerLabel(marker, newLabel); this._markerLayer.updateMarker(marker); + this.notifyTracksChanged(); } }, removeTrack: function(track) { track.visible(false); this.tracks.remove(track); + this.notifyTracksChanged(); }, deleteAllTracks: function() { @@ -1366,6 +1384,11 @@ L.Control.TrackList = L.Control.extend({ ); }, + loadTracksFromString(s, allowEmpty = false) { + const geodata = parseNktkSequence(s); + this.addTracksFromGeodataArray(geodata, allowEmpty); + }, + copyAllTracksToClipboard: function(mouseEvent) { this.copyTracksLinkToClipboard(this.tracks(), mouseEvent); }, @@ -1480,7 +1503,11 @@ L.Control.TrackList = L.Control.extend({ hasTracks: function() { return this.tracks().length > 0; - } + }, + + notifyTracksChanged() { + this.fire('trackschanged'); + }, } ); diff --git a/src/lib/leaflet.control.track-list/track-list.localstorage.js b/src/lib/leaflet.control.track-list/track-list.localstorage.js @@ -1,91 +0,0 @@ -import './track-list'; -import L from 'leaflet'; -import {parseNktkSequence} from './lib/parsers/nktk'; -import safeLocalStorage from '~/lib/safe-localstorage'; -import * as logging from '~/lib/logging'; - -L.Control.TrackList.include({ - maxLocalStorageSessions: 5, - - saveTracksToStorage: function() { - var tracks = this.tracks(), - serialized = [], - maxKey = -1, - i, track, s, key, m, - keys = []; - - for (i = 0; i < safeLocalStorage.length; i++) { - key = safeLocalStorage.key(i); - m = key.match(/^trackList_(\d+)$/u); - if (m && m[1] !== undefined) { - if (Number(m[1]) > maxKey) { - maxKey = Number(m[1]); - } - } - } - key = 'trackList_' + (maxKey + 1); - - if (tracks.length === 0) { - safeLocalStorage.setItem(key, ''); - return; - } - for (i = 0; i < tracks.length; i++) { - track = tracks[i]; - s = this.trackToString(track); - serialized.push(s); - } - if (serialized.length === 0) { - return; - } - s = '#nktk=' + serialized.join('/'); - - safeLocalStorage.setItem(key, s); - - // cleanup stale records - for (i = 0; i < safeLocalStorage.length; i++) { - key = safeLocalStorage.key(i); - m = key.match(/^trackList_(\d+)$/u); - if (m && m[1] !== undefined) { - keys.push(Number(m[1])); - } - } - if (keys.length > this.maxLocalStorageSessions) { - keys.sort(function(a, b) { - return a - b; - } - ); - for (i = 0; i < keys.length - this.maxLocalStorageSessions; i++) { - key = 'trackList_' + keys[i]; - safeLocalStorage.removeItem(key); - } - } - }, - - loadTracksFromStorage: function() { - var i, key, m, s, - geodata, - maxKey = -1; - - for (i = 0; i < safeLocalStorage.length; i++) { - key = safeLocalStorage.key(i); - m = key.match(/^trackList_(\d+)$/u); - if (m && m[1] !== undefined) { - if (Number(m[1]) > maxKey) { - maxKey = Number(m[1]); - } - } - } - if (maxKey > -1) { - key = 'trackList_' + maxKey; - s = safeLocalStorage.getItem(key); - safeLocalStorage.removeItem(key); - if (s) { - logging.captureBreadcrumb('load track from localStorage'); - s = s.slice(6); // remove "#nktk=" prefix - geodata = parseNktkSequence(s); - this.addTracksFromGeodataArray(geodata); - } - } - } - } -); diff --git a/src/lib/leaflet.hashState/hashState.js b/src/lib/leaflet.hashState/hashState.js @@ -110,12 +110,14 @@ const hashState = { } }; -function bindHashStateReadOnly(key, target) { +function bindHashStateReadOnly(key, target, once) { function onChange() { target(hashState.getState(key)); hashState.updateState(key, null); } - hashState.addEventListener(key, onChange); + if (!once) { + hashState.addEventListener(key, onChange); + } onChange(); } diff --git a/src/lib/session-state/index.js b/src/lib/session-state/index.js @@ -0,0 +1,195 @@ +import {openDB} from 'idb'; + +const EVENT_STORED_SESSIONS_CHANGED = 'storedsessionschanged'; +const EVENT_ACTIVE_SESSIONS_CHANGED = 'activesessionschanged'; + +class SessionRepository { + static DB_NAME = 'sessions'; + static STORE_NAME = 'sessionData'; + static MAX_HISTORY_ENTRIES = 100; + static MESSAGE_SESSION_CHANGED = 'sessionchanged'; + + constructor() { + this.channel = new BroadcastChannel('sessionrepository'); + this.channel.addEventListener('message', (e) => this.onChannelMessage(e)); + this.dbPromise = openDB(SessionRepository.DB_NAME, 1, { + upgrade(db) { + const store = db.createObjectStore(SessionRepository.STORE_NAME, {keyPath: 'sessionId'}); + store.createIndex('mtime', 'mtime', {unique: false}); + }, + }); + } + + onChannelMessage(e) { + if (e.data.message === SessionRepository.MESSAGE_SESSION_CHANGED) { + window.dispatchEvent(new Event(EVENT_STORED_SESSIONS_CHANGED)); + } + } + + async listSessionStates() { + const db = await this.dbPromise; + return db.getAll(SessionRepository.STORE_NAME); + } + + async getSessionState(sessionId) { + const db = await this.dbPromise; + const record = await db.get(SessionRepository.STORE_NAME, sessionId); + return record?.data; + } + + async pruneOldSessions() { + const db = await this.dbPromise; + const tx = db.transaction(SessionRepository.STORE_NAME, 'readwrite'); + const recordsCount = await tx.store.count(); + const recordsCountToDelete = recordsCount - SessionRepository.MAX_HISTORY_ENTRIES; + if (recordsCountToDelete > 0) { + let cursor = await tx.store.index('mtime').openCursor(); + for (let i = 0; i < recordsCountToDelete; i++) { + if (!cursor) { + break; + } + await cursor.delete(); + cursor = await cursor.continue(); + } + this.broadcastStorageChanged(); + } + } + + async setSessionState(sessionId, data) { + const db = await this.dbPromise; + await db.put(SessionRepository.STORE_NAME, { + sessionId, + mtime: Date.now(), + data, + }); + this.broadcastStorageChanged(); + this.pruneOldSessions(); + } + + async clearSessionState(sessionId) { + await (await this.dbPromise).delete(SessionRepository.STORE_NAME, sessionId); + this.broadcastStorageChanged(); + } + + broadcastStorageChanged() { + this.channel.postMessage({message: SessionRepository.MESSAGE_SESSION_CHANGED}); + } +} + +const sessionRepository = new SessionRepository(); + +class Session { + constructor() { + let sessionId = window.history.state?.sessionId; + if (!sessionId) { + sessionId = this.generateSessionId(); + window.history.replaceState({sessionId}, ''); + } + this.sessionId = sessionId; + window.addEventListener('popstate', () => window.history.replaceState({sessionId}, '')); + } + + generateSessionId() { + return Date.now().toString(36) + '_' + Math.random().toString(36).slice(2); + } + + async loadState() { + return sessionRepository.getSessionState(this.sessionId); + } + + async saveState(data) { + sessionRepository.setSessionState(this.sessionId, data); + } + + async clearState() { + sessionRepository.clearSessionState(this.sessionId); + } +} + +const session = new Session(); + +class ActiveSessionsMonitor { + static MESSAGE_REQUEST_SESSION = 'requestsessions'; + static MESSAGE_ACTIVE_SESSION = 'activesession'; + static MESSAGE_CLOSE_SESSION = 'closesession'; + + constructor() { + this.channel = new BroadcastChannel('sessions'); + this.channel.addEventListener('message', (e) => this.onChannelMessage(e)); + this._activeSessions = {}; + this.broadcastActiveSession(); + window.addEventListener('unload', () => this.broadcastSessionEnd()); + window.addEventListener('pagehide', () => this.broadcastSessionEnd()); + this.monitorRunning = false; + } + + onChannelMessage(e) { + switch (e.data.message) { + case ActiveSessionsMonitor.MESSAGE_REQUEST_SESSION: + this.broadcastActiveSession(); + break; + case ActiveSessionsMonitor.MESSAGE_ACTIVE_SESSION: + if (!this.monitorRunning) { + break; + } + this._activeSessions[e.data.sessionId] = true; + this.notifyActiveSessionsChanged(); + break; + case ActiveSessionsMonitor.MESSAGE_CLOSE_SESSION: + if (!this.monitorRunning) { + break; + } + delete this._activeSessions[e.data.sessionId]; + this.notifyActiveSessionsChanged(); + break; + default: + } + } + + startMonitor() { + this._activeSessions = {}; + this.monitorRunning = true; + this.requestActiveSessions(); + } + + stopMonitor() { + this.monitorRunning = false; + this._activeSessions = {}; + } + + broadcastActiveSession() { + this.channel.postMessage({ + message: ActiveSessionsMonitor.MESSAGE_ACTIVE_SESSION, + sessionId: session.sessionId, + }); + } + + broadcastSessionEnd() { + this.channel.postMessage({ + message: ActiveSessionsMonitor.MESSAGE_CLOSE_SESSION, + sessionId: session.sessionId, + }); + } + + notifyActiveSessionsChanged() { + window.dispatchEvent(new Event(EVENT_ACTIVE_SESSIONS_CHANGED)); + } + + requestActiveSessions() { + this.channel.postMessage({message: ActiveSessionsMonitor.MESSAGE_REQUEST_SESSION}); + } + + getActiveSessions() { + return Object.keys(this._activeSessions); + } +} + +const activeSessionsMonitor = new ActiveSessionsMonitor(); + +export { + session, + sessionRepository, + activeSessionsMonitor, + EVENT_STORED_SESSIONS_CHANGED, + EVENT_ACTIVE_SESSIONS_CHANGED, +}; diff --git a/yarn.lock b/yarn.lock @@ -4141,6 +4141,11 @@ icss-utils@^5.0.0, icss-utils@^5.1.0: resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== +idb@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/idb/-/idb-8.0.1.tgz#15e8be673413d6caf4beefacf086c8902d785e1e" + integrity sha512-EkBCzUZSdhJV8PxMSbeEV//xguVKZu9hZZulM+2gHXI0t2hGVU3eYE6/XnH77DS6FM2FY8wl17aDcu9vXpvLWQ== + ieee754@^1.1.12: version "1.1.13" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"