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:
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>
+ …total <span data-bind="text: data.trackNames.length"></span>
+ tracks…
+ </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>
+ …total <span data-bind="text: data.trackNames.length"></span>
+ tracks…
+ </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"