From 17128c58745f9ec90014cd4b70f74bab27964419 Mon Sep 17 00:00:00 2001 From: zadam Date: Tue, 11 Apr 2023 21:41:55 +0200 Subject: [PATCH] navigation state is more nicely and completely serialized into URL --- src/public/app/components/app_context.js | 4 +- src/public/app/components/entrypoints.js | 9 +- src/public/app/components/note_context.js | 32 +++++- src/public/app/components/tab_manager.js | 98 ++++++++++++------- src/public/app/services/link.js | 34 ++++++- src/public/app/services/tree.js | 52 +++++++--- .../app/widgets/buttons/history_navigation.js | 1 + src/public/app/widgets/note_title.js | 33 ++----- src/public/app/widgets/tab_row.js | 8 +- src/routes/index.js | 3 - src/services/window.js | 6 +- src/views/desktop.ejs | 2 - 12 files changed, 183 insertions(+), 99 deletions(-) diff --git a/src/public/app/components/app_context.js b/src/public/app/components/app_context.js index 32670f5a3..eae71d199 100644 --- a/src/public/app/components/app_context.js +++ b/src/public/app/components/app_context.js @@ -155,14 +155,14 @@ $(window).on('beforeunload', () => { $(window).on('hashchange', function() { if (treeService.isNotePathInAddress()) { - const [notePath, ntxId] = treeService.getHashValueFromAddress(); + const {notePath, ntxId, viewScope} = treeService.parseNavigationStateFromAddress(); if (!notePath && !ntxId) { console.log(`Invalid hash value "${document.location.hash}", ignoring.`); return; } - appContext.tabManager.switchToNoteContext(ntxId, notePath); + appContext.tabManager.switchToNoteContext(ntxId, notePath, viewScope); } }); diff --git a/src/public/app/components/entrypoints.js b/src/public/app/components/entrypoints.js index 7a4535e10..c09c45fbc 100644 --- a/src/public/app/components/entrypoints.js +++ b/src/public/app/components/entrypoints.js @@ -8,6 +8,7 @@ import toastService from "../services/toast.js"; import ws from "../services/ws.js"; import bundleService from "../services/bundle.js"; import froca from "../services/froca.js"; +import linkService from "../services/link.js"; export default class Entrypoints extends Component { constructor() { @@ -136,17 +137,15 @@ export default class Entrypoints extends Component { } async openInWindowCommand({notePath, hoistedNoteId, viewScope}) { - if (!hoistedNoteId) { - hoistedNoteId = 'root'; - } + const extraWindowHash = linkService.calculateHash({notePath, hoistedNoteId, viewScope}); if (utils.isElectron()) { const {ipcRenderer} = utils.dynamicRequire('electron'); - ipcRenderer.send('create-extra-window', {notePath, hoistedNoteId, viewScope}); + ipcRenderer.send('create-extra-window', { extraWindowHash }); } else { - const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}?extraWindow=1&extraHoistedNoteId=${hoistedNoteId}&extraViewScope=${JSON.stringify(viewScope)}#${notePath}`; + const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}?extraWindow=1${extraWindowHash}`; window.open(url, '', 'width=1000,height=800'); } diff --git a/src/public/app/components/note_context.js b/src/public/app/components/note_context.js index 12ffa4ba4..87cc72f43 100644 --- a/src/public/app/components/note_context.js +++ b/src/public/app/components/note_context.js @@ -12,13 +12,17 @@ class NoteContext extends Component { constructor(ntxId = null, hoistedNoteId = 'root', mainNtxId = null) { super(); - this.ntxId = ntxId || utils.randomString(4); + this.ntxId = ntxId || this.constructor.generateNtxId(); this.hoistedNoteId = hoistedNoteId; this.mainNtxId = mainNtxId; this.resetViewScope(); } + static generateNtxId() { + return utils.randomString(6); + } + setEmpty() { this.notePath = null; this.noteId = null; @@ -57,9 +61,8 @@ class NoteContext extends Component { utils.closeActiveDialog(); this.notePath = resolvedNotePath; - ({noteId: this.noteId, parentNoteId: this.parentNoteId} = treeService.getNoteIdAndParentIdFromNotePath(resolvedNotePath)); - this.viewScope = opts.viewScope; + ({noteId: this.noteId, parentNoteId: this.parentNoteId} = treeService.getNoteIdAndParentIdFromNotePath(resolvedNotePath)); this.saveToRecentNotes(resolvedNotePath); @@ -298,6 +301,29 @@ class NoteContext extends Component { // this is reset after navigating to a different note this.viewScope = {}; } + + async getNavigationTitle() { + if (!this.note) { + return null; + } + + const { note, viewScope } = this; + + let title = viewScope.viewMode === 'default' + ? note.title + : `${note.title}: ${viewScope.viewMode}`; + + if (viewScope.attachmentId) { + // assuming the attachment has been already loaded + const attachment = await note.getAttachmentById(viewScope.attachmentId); + + if (attachment) { + title += `: ${attachment.title}`; + } + } + + return title; + } } export default NoteContext; diff --git a/src/public/app/components/tab_manager.js b/src/public/app/components/tab_manager.js index ddf8860a9..91148cff1 100644 --- a/src/public/app/components/tab_manager.js +++ b/src/public/app/components/tab_manager.js @@ -8,6 +8,7 @@ import utils from "../services/utils.js"; import NoteContext from "./note_context.js"; import appContext from "./app_context.js"; import Mutex from "../utils/mutex.js"; +import linkService from "../services/link.js"; export default class TabManager extends Component { constructor() { @@ -53,45 +54,44 @@ export default class TabManager extends Component { ? (options.getJson('openTabs') || []) : []; - let filteredTabs = []; - // preload all notes at once await froca.getNotes([ ...tabsToOpen.map(tab => treeService.getNoteIdFromNotePath(tab.notePath)), ...tabsToOpen.map(tab => tab.hoistedNoteId), ], true); - for (const openTab of tabsToOpen) { - if (openTab.notePath && !(treeService.getNoteIdFromNotePath(openTab.notePath) in froca.notes)) { + const filteredTabs = tabsToOpen.filter(openTab => { + if (utils.isMobile()) { // mobile frontend doesn't have tabs so show only the active tab + return !!openTab.active; + } + + const noteId = treeService.getNoteIdFromNotePath(openTab.notePath); + if (!(noteId in froca.notes)) { // note doesn't exist so don't try to open tab for it - continue; + return false; } if (!(openTab.hoistedNoteId in froca.notes)) { openTab.hoistedNoteId = 'root'; } - filteredTabs.push(openTab); - } - - if (utils.isMobile()) { - // mobile frontend doesn't have tabs so show only the active tab - filteredTabs = filteredTabs.filter(tab => tab.active); - } + return true; + }); // resolve before opened tabs can change this - const [notePathInUrl, ntxIdInUrl] = treeService.getHashValueFromAddress(); + const parsedFromUrl = treeService.parseNavigationStateFromAddress(); if (filteredTabs.length === 0) { - filteredTabs.push({ - notePath: notePathInUrl || 'root', - active: true, - hoistedNoteId: glob.extraHoistedNoteId || 'root', - viewScope: glob.extraViewScope || {} - }); - } + parsedFromUrl.ntxId = parsedFromUrl.ntxId || NoteContext.generateNtxId(); // generate already here, so that we later know which one to activate - if (!filteredTabs.find(tab => tab.active)) { + filteredTabs.push({ + notePath: parsedFromUrl.notePath || 'root', + ntxId: parsedFromUrl.ntxId, + active: true, + hoistedNoteId: parsedFromUrl.hoistedNoteId || 'root', + viewScope: parsedFromUrl.viewScope || {} + }); + } else if (!filteredTabs.find(tab => tab.active)) { filteredTabs[0].active = true; } @@ -109,8 +109,13 @@ export default class TabManager extends Component { // if there's notePath in the URL, make sure it's open and active // (useful, for e.g. opening clipped notes from clipper or opening link in an extra window) - if (notePathInUrl) { - await appContext.tabManager.switchToNoteContext(ntxIdInUrl, notePathInUrl); + if (parsedFromUrl.notePath) { + await appContext.tabManager.switchToNoteContext( + parsedFromUrl.ntxId, + parsedFromUrl.notePath, + parsedFromUrl.viewScope, + parsedFromUrl.hoistedNoteId + ); } } catch (e) { @@ -123,28 +128,41 @@ export default class TabManager extends Component { noteSwitchedEvent({noteContext}) { if (noteContext.isActive()) { - this.setCurrentNotePathToHash(); + this.setCurrentNavigationStateToHash(); } this.tabsUpdate.scheduleUpdate(); } - setCurrentNotePathToHash() { - const activeNoteContext = this.getActiveContext(); - - if (window.history.length === 0 // first history entry - || (activeNoteContext && activeNoteContext.notePath !== treeService.getHashValueFromAddress()[0])) { - const url = `#${activeNoteContext.notePath || ""}-${activeNoteContext.ntxId}`; + setCurrentNavigationStateToHash() { + const calculatedHash = this.calculateHash(); + // update if it's the first history entry or there has been a change + if (window.history.length === 0 || calculatedHash !== window.location?.hash) { // using pushState instead of directly modifying document.location because it does not trigger hashchange - window.history.pushState(null, "", url); + window.history.pushState(null, "", calculatedHash); } + const activeNoteContext = this.getActiveContext(); this.updateDocumentTitle(activeNoteContext); this.triggerEvent('activeNoteChanged'); // trigger this even in on popstate event } + calculateHash() { + const activeNoteContext = this.getActiveContext(); + if (!activeNoteContext) { + return ""; + } + + return linkService.calculateHash({ + notePath: activeNoteContext.notePath, + ntxId: activeNoteContext.ntxId, + hoistedNoteId: activeNoteContext.hoistedNoteId, + viewScope: activeNoteContext.viewScope + }); + } + /** @returns {NoteContext[]} */ getNoteContexts() { return this.noteContexts; @@ -212,14 +230,18 @@ export default class TabManager extends Component { return activeNote ? activeNote.mime : null; } - async switchToNoteContext(ntxId, notePath) { + async switchToNoteContext(ntxId, notePath, viewScope = {}, hoistedNoteId = null) { const noteContext = this.noteContexts.find(nc => nc.ntxId === ntxId) || await this.openEmptyTab(); await this.activateNoteContext(noteContext.ntxId); + if (hoistedNoteId) { + await noteContext.setHoistedNoteId(hoistedNoteId); + } + if (notePath) { - await noteContext.setNote(notePath); + await noteContext.setNote(notePath, { viewScope }); } } @@ -347,7 +369,7 @@ export default class TabManager extends Component { this.tabsUpdate.scheduleUpdate(); - this.setCurrentNotePathToHash(); + this.setCurrentNavigationStateToHash(); } /** @@ -564,21 +586,21 @@ export default class TabManager extends Component { this.tabsUpdate.scheduleUpdate(); } - updateDocumentTitle(activeNoteContext) { + async updateDocumentTitle(activeNoteContext) { const titleFragments = [ // it helps to navigate in history if note title is included in the title - activeNoteContext.note?.title, + await activeNoteContext.getNavigationTitle(), "Trilium Notes" ].filter(Boolean); document.title = titleFragments.join(" - "); } - entitiesReloadedEvent({loadResults}) { + async entitiesReloadedEvent({loadResults}) { const activeContext = this.getActiveContext(); if (activeContext && loadResults.isNoteReloaded(activeContext.noteId)) { - this.updateDocumentTitle(activeContext); + await this.updateDocumentTitle(activeContext); } } } diff --git a/src/public/app/services/link.js b/src/public/app/services/link.js index a2eff7b65..4a63443fe 100644 --- a/src/public/app/services/link.js +++ b/src/public/app/services/link.js @@ -99,6 +99,37 @@ function parseNotePathAndScope($link) { }; } +function calculateHash({notePath, ntxId, hoistedNoteId, viewScope = {}}) { + notePath = notePath || ""; + const params = [ + ntxId ? { ntxId: ntxId } : null, + (hoistedNoteId && hoistedNoteId !== 'root') ? { hoistedNoteId: hoistedNoteId } : null, + viewScope.viewMode !== 'default' ? { viewMode: viewScope.viewMode } : null, + viewScope.attachmentId ? { attachmentId: viewScope.attachmentId } : null + ].filter(p => !!p); + + const paramStr = params.map(pair => { + const name = Object.keys(pair)[0]; + const value = pair[name]; + + return `${encodeURIComponent(name)}=${encodeURIComponent(value)}`; + }).join("&"); + + if (!notePath && !paramStr) { + return ""; + } + + let hash = `#${notePath}`; + + if (paramStr) { + hash += `?${paramStr}`; + } + + console.log(hash); + + return hash; +} + function goToLink(evt) { const $link = $(evt.target).closest("a,.block-link"); const hrefLink = $link.attr('href'); @@ -223,5 +254,6 @@ export default { createNoteLink, goToLink, loadReferenceLinkTitle, - parseNotePathAndScope + parseNotePathAndScope, + calculateHash }; diff --git a/src/public/app/services/tree.js b/src/public/app/services/tree.js index 3c4925f3e..2cc5803d9 100644 --- a/src/public/app/services/tree.js +++ b/src/public/app/services/tree.js @@ -23,8 +23,8 @@ async function resolveNotePath(notePath, hoistedNoteId = 'root') { async function resolveNotePathToSegments(notePath, hoistedNoteId = 'root', logErrors = true) { utils.assertArguments(notePath); - // we might get notePath with the ntxId suffix, remove it if present - notePath = notePath.split("-")[0].trim(); + // we might get notePath with the params suffix, remove it if present + notePath = notePath.split("?")[0].trim(); if (notePath.length === 0) { return; @@ -159,8 +159,8 @@ function getNoteIdFromNotePath(notePath) { const lastSegment = path[path.length - 1]; - // path could have also ntxId suffix - return lastSegment.split("-")[0]; + // path could have also params suffix + return lastSegment.split("?")[0]; } async function getBranchIdFromNotePath(notePath) { @@ -185,8 +185,8 @@ function getNoteIdAndParentIdFromNotePath(notePath) { const lastSegment = path[path.length - 1]; - // path could have also ntxId suffix - noteId = lastSegment.split("-")[0]; + // path could have also params suffix + noteId = lastSegment.split("?")[0]; if (path.length > 1) { parentNoteId = path[path.length - 2]; @@ -297,14 +297,44 @@ async function getNoteTitleWithPathAsSuffix(notePath) { return $titleWithPath; } -function getHashValueFromAddress() { - const str = document.location.hash ? document.location.hash.substr(1) : ""; // strip initial # +function parseNavigationStateFromAddress() { + const str = document.location.hash?.substr(1) || ""; // strip initial # - return str.split("-"); + const [notePath, paramString] = str.split("?"); + const viewScope = { + viewMode: 'default' + }; + let ntxId = null; + let hoistedNoteId = null; + + if (paramString) { + for (const pair of paramString.split("&")) { + let [name, value] = pair.split("="); + name = decodeURIComponent(name); + value = decodeURIComponent(value); + + if (name === 'ntxId') { + ntxId = value; + } else if (name === 'hoistedNoteId') { + hoistedNoteId = value; + } else if (['viewMode', 'attachmentId'].includes(name)) { + viewScope[name] = value; + } else { + console.warn(`Unrecognized hash parameter '${name}'.`); + } + } + } + + return { + notePath, + ntxId, + hoistedNoteId, + viewScope + }; } function isNotePathInAddress() { - const [notePath, ntxId] = getHashValueFromAddress(); + const {notePath, ntxId} = parseNavigationStateFromAddress(); return notePath.startsWith("root") // empty string is for empty/uninitialized tab @@ -338,7 +368,7 @@ export default { getNoteTitle, getNotePathTitle, getNoteTitleWithPathAsSuffix, - getHashValueFromAddress, + parseNavigationStateFromAddress, isNotePathInAddress, parseNotePath, isNotePathInHiddenSubtree diff --git a/src/public/app/widgets/buttons/history_navigation.js b/src/public/app/widgets/buttons/history_navigation.js index 08a86810f..77bdea2c7 100644 --- a/src/public/app/widgets/buttons/history_navigation.js +++ b/src/public/app/widgets/buttons/history_navigation.js @@ -55,6 +55,7 @@ export default class HistoryNavigationButton extends ButtonFromNoteWidget { for (const idx in this.webContents.history) { const url = this.webContents.history[idx]; const [_, notePathWithTab] = url.split('#'); + // broken: use treeService.parseNavigationStateFromAddress(); const [notePath, ntxId] = notePathWithTab.split('-'); const title = await treeService.getNotePathTitle(notePath); diff --git a/src/public/app/widgets/note_title.js b/src/public/app/widgets/note_title.js index 24290b2b1..08505627a 100644 --- a/src/public/app/widgets/note_title.js +++ b/src/public/app/widgets/note_title.js @@ -70,37 +70,20 @@ export default class NoteTitleWidget extends NoteContextAwareWidget { } async refreshWithNote(note) { - this.$noteTitle.val(await this.getTitleText(note)); - - this.$noteTitle.prop("readonly", - (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) + const isReadOnly = (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) || ['_lbRoot', '_lbAvailableLaunchers', '_lbVisibleLaunchers'].includes(note.noteId) - || this.noteContext.viewScope.viewMode !== 'default' + || this.noteContext.viewScope.viewMode !== 'default'; + + this.$noteTitle.val( + isReadOnly + ? await this.noteContext.getNavigationTitle() + : note.title ); + this.$noteTitle.prop("readonly", isReadOnly); this.setProtectedStatus(note); } - /** @param {FNote} note */ - async getTitleText(note) { - const viewScope = this.noteContext.viewScope; - - let title = viewScope.viewMode === 'default' - ? note.title - : `${note.title}: ${viewScope.viewMode}`; - - if (viewScope.attachmentId) { - // assuming the attachment has been already loaded - const attachment = await note.getAttachmentById(viewScope.attachmentId); - - if (attachment) { - title += `: ${attachment.title}`; - } - } - - return title; - } - /** @param {FNote} note */ setProtectedStatus(note) { this.$noteTitle.toggleClass("protected", !!note.isProtected); diff --git a/src/public/app/widgets/tab_row.js b/src/public/app/widgets/tab_row.js index 55a5da24c..a2efcc8ab 100644 --- a/src/public/app/widgets/tab_row.js +++ b/src/public/app/widgets/tab_row.js @@ -618,7 +618,7 @@ export default class TabRowWidget extends BasicWidget { } /** @param {NoteContext} noteContext */ - updateTab($tab, noteContext) { + async updateTab($tab, noteContext) { if (!$tab.length) { return; } @@ -652,11 +652,7 @@ export default class TabRowWidget extends BasicWidget { return; } - const viewMode = noteContext.viewScope?.viewMode; - const title = (viewMode && viewMode !== 'default') - ? `${viewMode}: ${note.title}` - : note.title; - + const title = await noteContext.getNavigationTitle(); this.updateTitle($tab, title); $tab.addClass(note.getCssClass()); diff --git a/src/routes/index.js b/src/routes/index.js index c158a1842..e83b4fcd4 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -35,9 +35,6 @@ function index(req, res) { appCssNoteIds: getAppCssNoteIds(), isDev: env.isDev(), isMainWindow: !req.query.extraWindow, - extraHoistedNoteId: req.query.extraHoistedNoteId, - // make sure only valid JSON gets rendered - extraViewScope: JSON.stringify(req.query.extraViewScope ? JSON.parse(req.query.extraViewScope) : {}), isProtectedSessionAvailable: protectedSessionService.isProtectedSessionAvailable(), maxContentWidth: parseInt(options.maxContentWidth), triliumVersion: packageJson.version, diff --git a/src/services/window.js b/src/services/window.js index e41e3d8f1..4e26f362e 100644 --- a/src/services/window.js +++ b/src/services/window.js @@ -15,7 +15,7 @@ let mainWindow; /** @type {Electron.BrowserWindow} */ let setupWindow; -async function createExtraWindow(notePath, hoistedNoteId = 'root', viewScope = {}) { +async function createExtraWindow(extraWindowHash) { const spellcheckEnabled = optionService.getOptionBool('spellCheckEnabled'); const {BrowserWindow} = require('electron'); @@ -35,13 +35,13 @@ async function createExtraWindow(notePath, hoistedNoteId = 'root', viewScope = { }); win.setMenuBarVisibility(false); - win.loadURL(`http://127.0.0.1:${port}/?extraWindow=1&extraHoistedNoteId=${hoistedNoteId}&extraViewScope=${JSON.stringify(viewScope)}#${notePath}`); + win.loadURL(`http://127.0.0.1:${port}/?extraWindow=1${extraWindowHash}`); configureWebContents(win.webContents, spellcheckEnabled); } ipcMain.on('create-extra-window', (event, arg) => { - createExtraWindow(arg.notePath, arg.hoistedNoteId, arg.viewScope); + createExtraWindow(arg.extraWindowHash); }); async function createMainWindow(app) { diff --git a/src/views/desktop.ejs b/src/views/desktop.ejs index fb9591792..d4635c6c1 100644 --- a/src/views/desktop.ejs +++ b/src/views/desktop.ejs @@ -32,8 +32,6 @@ isDev: <%= isDev %>, appCssNoteIds: <%- JSON.stringify(appCssNoteIds) %>, isMainWindow: <%= isMainWindow %>, - extraHoistedNoteId: '<%= extraHoistedNoteId %>', - extraViewScope: <%- extraViewScope %>, isProtectedSessionAvailable: <%= isProtectedSessionAvailable %>, triliumVersion: "<%= triliumVersion %>", assetPath: "<%= assetPath %>",