nakarte

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

index.js (15796B)


      1 import md5 from 'blueimp-md5';
      2 import ko from 'knockout';
      3 import L from 'leaflet';
      4 
      5 import {makeButton} from '~/lib/leaflet.control.commons';
      6 import {parseNktkSequence} from '~/lib/leaflet.control.track-list/lib/parsers/nktk';
      7 import {bindHashStateReadOnly} from '~/lib/leaflet.hashState/hashState';
      8 import {notify} from '~/lib/notifications';
      9 import {
     10     activeSessionsMonitor,
     11     EVENT_ACTIVE_SESSIONS_CHANGED,
     12     EVENT_STORED_SESSIONS_CHANGED,
     13     session,
     14     sessionRepository,
     15 } from '~/lib/session-state';
     16 
     17 import './style.css';
     18 
     19 function formatDateTime(ts) {
     20     const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
     21     const date = new Date(ts);
     22 
     23     const month = months[date.getMonth()];
     24     const hours = date.getHours().toString().padStart(2, '0');
     25     const minutes = date.getMinutes().toString().padStart(2, '0');
     26     const seconds = date.getSeconds().toString().padStart(2, '0');
     27     return `${month} ${date.getDate()} ${date.getFullYear()} ${hours}:${minutes}:${seconds}`;
     28 }
     29 
     30 function temporaryDisableAfterInvocation(f, delay) {
     31     let lastCalledAt = null;
     32 
     33     function wrapper(...args) {
     34         if (lastCalledAt === null || lastCalledAt + delay < Date.now()) {
     35             try {
     36                 f(...args);
     37             } finally {
     38                 lastCalledAt = Date.now();
     39             }
     40         }
     41     }
     42 
     43     return wrapper;
     44 }
     45 
     46 const SessionsControl = L.Control.extend({
     47     includes: L.Mixin.Events,
     48 
     49     initialize: function (trackListControl, options) {
     50         L.Control.prototype.initialize.call(this, options);
     51         this.trackListControl = trackListControl;
     52         this.sessionListWindowVisible = false;
     53         this.loadingState = false;
     54         this.channel = new BroadcastChannel('session-control');
     55         this.channel.addEventListener('message', (e) => this.onChannelMessage(e));
     56         this.saveCurrentState = L.Util.throttle(this.saveCurrentStateImmediate, 1000, this);
     57         this.trackListControl.on('trackschanged', () => this.onCurrentStateChange());
     58         window.addEventListener('hashchange', () => this.onCurrentStateChange());
     59         window.addEventListener('unload', () => this.saveCurrentStateImmediate());
     60         window.addEventListener('pagehide', () => this.saveCurrentStateImmediate());
     61         this.canSwitchFocus = !L.Browser.mobile;
     62 
     63         this.sessionListWindowModel = {
     64             activeSessions: ko.observableArray([]),
     65             inactiveSessions: ko.observableArray([]),
     66             visible: ko.observable(false),
     67             formatDateTime,
     68             maxTrackLines: 4,
     69             requestSwitchFocus: (sessionData) => this.requestSwitchFocus(sessionData.sessionId),
     70             openStoredSession: temporaryDisableAfterInvocation(
     71                 (sessionData) => this.openStoredSession(sessionData.sessionId),
     72                 200
     73             ),
     74             closeWindow: () => this.hideSessionListWindow(),
     75             canSwitchFocus: this.canSwitchFocus,
     76         };
     77     },
     78 
     79     setupSessionListWindow: function () {
     80         const layout = `
     81             <div data-bind="visible: visible" class="leaflet-control-session-list-wrapper">
     82                 <div class="leaflet-control-session-list-window">
     83                     <div class="leaflet-control-session-list-window-header">
     84                         <div class="button-close" data-bind="click: closeWindow"></div>
     85                     </div>
     86                     <div class="leaflet-control-session-list-scrollbox">
     87                         <!-- ko if: activeSessions().length -->
     88                             <div class="leaflet-control-session-list-header">
     89                                 Active sessions with tracks in other tabs
     90                             </div>
     91                             <!-- ko if: canSwitchFocus -->
     92                                 <div class="leaflet-control-session-list-header-info">Click to switch tab</div>
     93                             <!-- /ko -->
     94                         <!-- /ko -->
     95                         <!-- ko foreach: activeSessions -->
     96                             <div
     97                                 class="leaflet-control-session-list-item-active"
     98                                 data-bind="
     99                                     attr: {title: data.trackNames.join('\\n')},
    100                                     click: $root.requestSwitchFocus,
    101                                     css: {'click-disabled': !$root.canSwitchFocus}">
    102                                 <!-- ko foreach: data.trackNames.length <= $root.maxTrackLines
    103                                     ? data.trackNames
    104                                     : data.trackNames.slice(0, $root.maxTrackLines - 1)
    105                                 -->
    106                                     <div class="leaflet-control-session-list-item-track" data-bind="text: $data"></div>
    107                                 <!-- /ko -->
    108                                 <!-- ko if: data.trackNames.length > $root.maxTrackLines -->
    109                                     <div>
    110                                         &hellip;total <span data-bind="text: data.trackNames.length"></span>
    111                                         tracks&hellip;
    112                                     </div>
    113                                 <!-- /ko -->
    114                             </div>
    115                         <!-- /ko -->
    116 
    117                         <!-- ko if: inactiveSessions().length -->
    118                             <div class="leaflet-control-session-list-header">Recently opened sessions with tracks</div>
    119                             <div class="leaflet-control-session-list-header-info">Click to open in new tab</div>
    120                         <!-- /ko -->
    121 
    122                         <!-- ko foreach: inactiveSessions -->
    123                             <div
    124                                 class="leaflet-control-session-list-item-inactive"
    125                                 data-bind="attr: {title: data.trackNames.join('\\n')}, click: $root.openStoredSession"
    126                             >
    127                                 <div class="leaflet-control-session-list-item-date">
    128                                     Last used at <span data-bind="text: $root.formatDateTime($data.mtime)"></span>
    129                                 </div>
    130                                 <!-- ko foreach: data.trackNames.length <= $root.maxTrackLines
    131                                     ? data.trackNames
    132                                     : data.trackNames.slice(0, $root.maxTrackLines - 1)
    133                                 -->
    134                                     <div class="leaflet-control-session-list-item-track" data-bind="text: $data"></div>
    135                                 <!-- /ko -->
    136                                 <!-- ko if: data.trackNames.length > $root.maxTrackLines -->
    137                                     <div>
    138                                         &hellip;total <span data-bind="text: data.trackNames.length"></span>
    139                                         tracks&hellip;
    140                                     </div>
    141                                 <!-- /ko -->
    142 
    143                             </div>
    144                         <!-- /ko -->
    145                         <!-- ko if: !activeSessions().length && !inactiveSessions().length -->
    146                         <div class="leaflet-control-session-list-header">No recent sessions with tracks</div>
    147                         <!-- /ko -->
    148                     </div>
    149                 </div>
    150             </div>
    151         `;
    152 
    153         const container = L.DomUtil.create('div');
    154         container.innerHTML = layout;
    155         const sessionListWindow = container.querySelector('.leaflet-control-session-list-window');
    156         L.DomEvent.disableClickPropagation(sessionListWindow);
    157         L.DomEvent.disableScrollPropagation(sessionListWindow);
    158         ko.applyBindings(this.sessionListWindowModel, container);
    159         this._map._controlContainer.appendChild(container);
    160     },
    161 
    162     onAdd: function () {
    163         let container;
    164         if (this.options.noButton) {
    165             container = L.DomUtil.create('div');
    166             container.style.margin = '0';
    167         } else {
    168             const {container: buttonContainer, link} = makeButton(null, 'Recent sessions');
    169             L.DomEvent.on(link, 'click', () => this.toggleSessionListsVisible());
    170             container = buttonContainer;
    171         }
    172         this.setupSessionListWindow();
    173         return container;
    174     },
    175 
    176     onChannelMessage: function (e) {
    177         const messageData = e.data;
    178         if (messageData.message === 'focus' && messageData.sessionId === session.sessionId) {
    179             this.switchFocus();
    180         }
    181     },
    182 
    183     onActiveSessionsChange: function () {
    184         this.updateSessionLists();
    185     },
    186 
    187     onStoredSessionsChange: function () {
    188         this.updateSessionLists();
    189     },
    190 
    191     onCurrentStateChange: function () {
    192         if (!this.loadingState) {
    193             this.saveCurrentState();
    194         }
    195     },
    196 
    197     toggleSessionListsVisible: function () {
    198         if (this.sessionListWindowVisible) {
    199             this.hideSessionListWindow();
    200         } else {
    201             this.showSessionListWindow();
    202         }
    203     },
    204 
    205     showSessionListWindow: function () {
    206         if (this.sessionListWindowVisible) {
    207             return;
    208         }
    209         this.sessionListWindowVisible = true;
    210         this.updateSessionLists();
    211         this.sessionListWindowModel.visible(true);
    212         this.setupEventsForSessionListWindow(true);
    213         activeSessionsMonitor.startMonitor();
    214     },
    215 
    216     hideSessionListWindow: function () {
    217         if (!this.sessionListWindowVisible) {
    218             return;
    219         }
    220         this.sessionListWindowVisible = false;
    221         this.sessionListWindowModel.visible(false);
    222         activeSessionsMonitor.stopMonitor();
    223         this.setupEventsForSessionListWindow(false);
    224     },
    225 
    226     setupEventsForSessionListWindow: function (on) {
    227         L.DomEvent[on ? 'on' : 'off'](
    228             window,
    229             {
    230                 keydown: this.onKeyDown,
    231                 [EVENT_ACTIVE_SESSIONS_CHANGED]: this.onActiveSessionsChange,
    232                 [EVENT_STORED_SESSIONS_CHANGED]: this.onStoredSessionsChange,
    233             },
    234             this
    235         );
    236     },
    237 
    238     onKeyDown: function (e) {
    239         if (e.keyCode === 27) {
    240             this.hideSessionListWindow();
    241         }
    242     },
    243 
    244     requestSwitchFocus: async function (sessionId) {
    245         if (!this.canSwitchFocus) {
    246             return;
    247         }
    248         if (!window.Notification) {
    249             notify('Can not switch to another window, your browser does not support notifications.');
    250             return;
    251         }
    252         if (window.Notification.permission !== 'granted') {
    253             notify('Please allow notifications to be able to switch to other sessions.');
    254             await Notification.requestPermission();
    255         }
    256         this.channel.postMessage({message: 'focus', sessionId});
    257     },
    258 
    259     openStoredSession: async function (sessionId) {
    260         // Opening window before await-ing for promise helps to avoid new window being blocked in Firefox
    261         const newWindow = window.open('', '_blank');
    262         const sessionData = await sessionRepository.getSessionState(sessionId);
    263         if (!sessionData) {
    264             newWindow.close();
    265             notify("Saved session was lost. Maybe it's a bug.");
    266             return;
    267         }
    268         const {origin, pathname} = window.location; // eslint-disable-line no-shadow
    269         newWindow.location = `${origin}${pathname}${sessionData.hash}&sid=${sessionId}`;
    270         newWindow.focus();
    271     },
    272 
    273     switchFocus: function () {
    274         const notification = new Notification('Switch nakarte.me window', {
    275             body: 'Click here to switch nakarte.me window.',
    276         });
    277         notification.addEventListener('click', () => {
    278             parent.focus();
    279             window.focus();
    280             notification.close();
    281         });
    282     },
    283 
    284     saveCurrentStateImmediate: function () {
    285         const tracks = this.trackListControl.tracks();
    286         if (!tracks.length) {
    287             session.clearState();
    288             return;
    289         }
    290         const {hash} = window.location;
    291         const trackNames = tracks.map((track) => track.name());
    292         const tracksSerialized = this.trackListControl.serializeTracks(tracks);
    293         session.saveState({hash, tracks: tracksSerialized, trackNames});
    294     },
    295 
    296     loadSession: async function () {
    297         const sessionSavedTracks = (await session.loadState())?.tracks;
    298         if (sessionSavedTracks) {
    299             this.loadingState = true;
    300             try {
    301                 this.trackListControl.loadTracksFromString(sessionSavedTracks, true);
    302             } finally {
    303                 this.loadingState = false;
    304             }
    305             // if session state is not found, it can be caused by
    306             // 1. tab was restored using Ctrl-Shift-T but session was pruned (very unlikely)
    307             // 2. session was opened using session list control in another tab
    308             // 3. there were no tracks in this session (most likely).
    309             // If we want to show notification for case 2. then we will need to store sessions without
    310             // tracks as well.
    311         }
    312         this.saveCurrentStateImmediate();
    313     },
    314 
    315     consumeSessionFromHash: function () {
    316         bindHashStateReadOnly(
    317             'sid',
    318             async (params) => {
    319                 if (!params) {
    320                     return;
    321                 }
    322                 const sessionId = params[0];
    323                 const sessionState = await sessionRepository.getSessionState(sessionId);
    324                 if (!sessionState) {
    325                     notify("Saved session was lost. Maybe it's a bug.");
    326                     return;
    327                 }
    328                 this.loadingState = true;
    329                 try {
    330                     this.trackListControl.loadTracksFromString(sessionState.tracks, true);
    331                 } finally {
    332                     this.loadingState = false;
    333                 }
    334                 this.saveCurrentStateImmediate();
    335                 await sessionRepository.clearSessionState(sessionId);
    336             },
    337             true
    338         );
    339     },
    340 
    341     importOldSessions: async function () {
    342         const oldDataPrefix = '#nktk=';
    343         let imported = false;
    344         for (const [key, value] of Object.entries(window.localStorage)) {
    345             const m = key.match(/^trackList_\d+$/u);
    346             if (m && value.startsWith(oldDataPrefix)) {
    347                 const tracksSerialized = value.slice(oldDataPrefix.length);
    348                 const geodata = parseNktkSequence(tracksSerialized);
    349                 const trackNames = geodata.map((track) => track.name);
    350                 const sessionId = 'imported_' + md5(tracksSerialized);
    351                 await sessionRepository.setSessionState(sessionId, {hash: '#', tracks: tracksSerialized, trackNames});
    352                 delete window.localStorage[key];
    353                 imported = true;
    354             }
    355         }
    356         return imported;
    357     },
    358 
    359     updateSessionLists: async function () {
    360         const storedSessions = (await sessionRepository.listSessionStates()).filter(
    361             (sess) => sess.sessionId !== session.sessionId
    362         );
    363         const activeSessionIds = activeSessionsMonitor.getActiveSessions();
    364         const activeSessions = storedSessions.filter((sess) => activeSessionIds.includes(sess.sessionId));
    365         const inactiveSessions = storedSessions.filter((sess) => !activeSessionIds.includes(sess.sessionId));
    366         inactiveSessions.sort((sess1, sess2) => sess2.mtime - sess1.mtime);
    367         // no need to sort active sessions, IndexedDb returns them sorted by sessionId which is fine.
    368         this.sessionListWindowModel.activeSessions(activeSessions);
    369         this.sessionListWindowModel.inactiveSessions(inactiveSessions);
    370     },
    371 });
    372 export {SessionsControl};