import Component from "../widgets/component.js"; import SpacedUpdate from "./spaced_update.js"; import server from "./server.js"; import options from "./options.js"; import treeCache from "./tree_cache.js"; import treeService from "./tree.js"; import utils from "./utils.js"; import TabContext from "./tab_context.js"; import appContext from "./app_context.js"; export default class TabManager extends Component { constructor() { super(); this.activeTabId = null; this.tabsUpdate = new SpacedUpdate(async () => { if (!appContext.isMainWindow) { return; } const openTabs = this.tabContexts .map(tc => tc.getTabState()) .filter(t => !!t); await server.put('options', { openTabs: JSON.stringify(openTabs) }); }); } /** @type {TabContext[]} */ get tabContexts() { return this.children; } async loadTabs() { const tabsToOpen = appContext.isMainWindow ? (options.getJson('openTabs') || []) : []; // if there's notePath in the URL, make sure it's open and active // (useful, among others, for opening clipped notes from clipper) if (window.location.hash) { const notePath = window.location.hash.substr(1); const noteId = treeService.getNoteIdFromNotePath(notePath); if (noteId && await treeCache.noteExists(noteId)) { for (const tab of tabsToOpen) { tab.active = false; } const foundTab = tabsToOpen.find(tab => noteId === treeService.getNoteIdFromNotePath(tab.notePath)); if (foundTab) { foundTab.active = true; } else { tabsToOpen.push({ notePath: notePath, active: true }); } } } let filteredTabs = []; for (const openTab of tabsToOpen) { const noteId = treeService.getNoteIdFromNotePath(openTab.notePath); if (await treeCache.noteExists(noteId)) { // note doesn't exist so don't try to open tab for it filteredTabs.push(openTab); } } if (utils.isMobile()) { // mobile frontend doesn't have tabs so show only the active tab filteredTabs = filteredTabs.filter(tab => tab.active); } if (filteredTabs.length === 0) { filteredTabs.push({ notePath: this.isMainWindow ? 'root' : '', active: true }); } if (!filteredTabs.find(tab => tab.active)) { filteredTabs[0].active = true; } await this.tabsUpdate.allowUpdateWithoutChange(async () => { for (const tab of filteredTabs) { await this.openTabWithNote(tab.notePath, tab.active, tab.tabId); } }); } tabNoteSwitchedEvent({tabContext}) { if (tabContext.isActive()) { this.setCurrentNotePathToHash(); } this.tabsUpdate.scheduleUpdate(); } setCurrentNotePathToHash() { const activeTabContext = this.getActiveTabContext(); if (window.history.length === 0 // first history entry || (activeTabContext && activeTabContext.notePath !== treeService.getHashValueFromAddress()[0])) { const url = '#' + (activeTabContext.notePath || "") + "-" + activeTabContext.tabId; // using pushState instead of directly modifying document.location because it does not trigger hashchange window.history.pushState(null, "", url); document.title = "Trilium Notes"; if (activeTabContext.note) { // it helps navigating in history if note title is included in the title document.title += " - " + activeTabContext.note.title; } } this.triggerEvent('activeNoteChanged'); // trigger this even in on popstate event } /** @return {TabContext[]} */ getTabContexts() { return this.tabContexts; } /** @returns {TabContext} */ getTabContextById(tabId) { return this.tabContexts.find(tc => tc.tabId === tabId); } /** @returns {TabContext} */ getActiveTabContext() { return this.getTabContextById(this.activeTabId); } /** @returns {string|null} */ getActiveTabNotePath() { const activeContext = this.getActiveTabContext(); return activeContext ? activeContext.notePath : null; } /** @return {NoteShort} */ getActiveTabNote() { const activeContext = this.getActiveTabContext(); return activeContext ? activeContext.note : null; } /** @return {string|null} */ getActiveTabNoteId() { const activeNote = this.getActiveTabNote(); return activeNote ? activeNote.noteId : null; } /** @return {string|null} */ getActiveTabNoteType() { const activeNote = this.getActiveTabNote(); return activeNote ? activeNote.type : null; } async switchToTab(tabId, notePath) { const tabContext = this.tabContexts.find(tc => tc.tabId === tabId) || await this.openEmptyTab(); this.activateTab(tabContext.tabId); await tabContext.setNote(notePath); } async openAndActivateEmptyTab() { const tabContext = await this.openEmptyTab(); await this.activateTab(tabContext.tabId); await tabContext.setEmpty(); } async openEmptyTab(tabId) { const tabContext = new TabContext(tabId); this.child(tabContext); await this.triggerEvent('newTabOpened', {tabContext}); return tabContext; } async openTabWithNote(notePath, activate, tabId = null) { const tabContext = await this.openEmptyTab(tabId); if (notePath) { await tabContext.setNote(notePath, !activate); // if activate is false then send normal noteSwitched event } if (activate) { this.activateTab(tabContext.tabId, false); await this.triggerEvent('tabNoteSwitchedAndActivated', { tabContext, notePath: tabContext.notePath // resolved note path }); } return tabContext; } async activateOrOpenNote(noteId) { for (const tabContext of this.getTabContexts()) { if (tabContext.note && tabContext.note.noteId === noteId) { this.activateTab(tabContext.tabId); return; } } // if no tab with this note has been found we'll create new tab await this.openTabWithNote(noteId, true); } activateTab(tabId, triggerEvent = true) { if (tabId === this.activeTabId) { return; } this.activeTabId = tabId; if (triggerEvent) { this.triggerEvent('activeTabChanged', { tabContext: this.getTabContextById(tabId) }); } this.tabsUpdate.scheduleUpdate(); this.setCurrentNotePathToHash(); } async removeTab(tabId) { const tabContextToRemove = this.getTabContextById(tabId); if (!tabContextToRemove) { return; } // close dangling autocompletes after closing the tab $(".aa-input").autocomplete("close"); await this.triggerEvent('beforeTabRemove', {tabId}); if (this.tabContexts.length <= 1) { this.openAndActivateEmptyTab(); } else if (tabContextToRemove.isActive()) { const idx = this.tabContexts.findIndex(tc => tc.tabId === tabId); if (idx === this.tabContexts.length - 1) { this.activatePreviousTabCommand(); } else { this.activateNextTabCommand(); } } this.children = this.children.filter(tc => tc.tabId !== tabId); this.triggerEvent('tabRemoved', {tabId}); this.tabsUpdate.scheduleUpdate(); } tabReorderEvent({tabIdsInOrder}) { const order = {}; for (const i in tabIdsInOrder) { order[tabIdsInOrder[i]] = i; } this.children.sort((a, b) => order[a.tabId] < order[b.tabId] ? -1 : 1); this.tabsUpdate.scheduleUpdate(); } activateNextTabCommand() { const oldIdx = this.tabContexts.findIndex(tc => tc.tabId === this.activeTabId); const newActiveTabId = this.tabContexts[oldIdx === this.tabContexts.length - 1 ? 0 : oldIdx + 1].tabId; this.activateTab(newActiveTabId); } activatePreviousTabCommand() { const oldIdx = this.tabContexts.findIndex(tc => tc.tabId === this.activeTabId); const newActiveTabId = this.tabContexts[oldIdx === 0 ? this.tabContexts.length - 1 : oldIdx - 1].tabId; this.activateTab(newActiveTabId); } closeActiveTabCommand() { this.removeTab(this.activeTabId); } beforeUnloadEvent() { this.tabsUpdate.updateNowIfNecessary(); } openNewTabCommand() { this.openAndActivateEmptyTab(); } async removeAllTabsCommand() { for (const tabIdToRemove of this.tabContexts.map(tc => tc.tabId)) { await this.removeTab(tabIdToRemove); } } async removeAllTabsExceptForThisCommand({tabId}) { for (const tabIdToRemove of this.tabContexts.map(tc => tc.tabId)) { if (tabIdToRemove !== tabId) { await this.removeTab(tabIdToRemove); } } } moveTabToNewWindowCommand({tabId}) { const notePath = this.getTabContextById(tabId).notePath; this.removeTab(tabId); this.triggerCommand('openInWindow', {notePath}); } async hoistedNoteChangedEvent({hoistedNoteId}) { if (hoistedNoteId === 'root') { return; } for (const tc of this.tabContexts.splice()) { if (tc.notePath && !tc.notePath.split("/").includes(hoistedNoteId)) { await this.removeTab(tc.tabId); } } } }