navigation state is more nicely and completely serialized into URL

This commit is contained in:
zadam 2023-04-11 21:41:55 +02:00
parent 9e71c44c76
commit 17128c5874
12 changed files with 183 additions and 99 deletions

View File

@ -155,14 +155,14 @@ $(window).on('beforeunload', () => {
$(window).on('hashchange', function() { $(window).on('hashchange', function() {
if (treeService.isNotePathInAddress()) { if (treeService.isNotePathInAddress()) {
const [notePath, ntxId] = treeService.getHashValueFromAddress(); const {notePath, ntxId, viewScope} = treeService.parseNavigationStateFromAddress();
if (!notePath && !ntxId) { if (!notePath && !ntxId) {
console.log(`Invalid hash value "${document.location.hash}", ignoring.`); console.log(`Invalid hash value "${document.location.hash}", ignoring.`);
return; return;
} }
appContext.tabManager.switchToNoteContext(ntxId, notePath); appContext.tabManager.switchToNoteContext(ntxId, notePath, viewScope);
} }
}); });

View File

@ -8,6 +8,7 @@ import toastService from "../services/toast.js";
import ws from "../services/ws.js"; import ws from "../services/ws.js";
import bundleService from "../services/bundle.js"; import bundleService from "../services/bundle.js";
import froca from "../services/froca.js"; import froca from "../services/froca.js";
import linkService from "../services/link.js";
export default class Entrypoints extends Component { export default class Entrypoints extends Component {
constructor() { constructor() {
@ -136,17 +137,15 @@ export default class Entrypoints extends Component {
} }
async openInWindowCommand({notePath, hoistedNoteId, viewScope}) { async openInWindowCommand({notePath, hoistedNoteId, viewScope}) {
if (!hoistedNoteId) { const extraWindowHash = linkService.calculateHash({notePath, hoistedNoteId, viewScope});
hoistedNoteId = 'root';
}
if (utils.isElectron()) { if (utils.isElectron()) {
const {ipcRenderer} = utils.dynamicRequire('electron'); const {ipcRenderer} = utils.dynamicRequire('electron');
ipcRenderer.send('create-extra-window', {notePath, hoistedNoteId, viewScope}); ipcRenderer.send('create-extra-window', { extraWindowHash });
} }
else { 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'); window.open(url, '', 'width=1000,height=800');
} }

View File

@ -12,13 +12,17 @@ class NoteContext extends Component {
constructor(ntxId = null, hoistedNoteId = 'root', mainNtxId = null) { constructor(ntxId = null, hoistedNoteId = 'root', mainNtxId = null) {
super(); super();
this.ntxId = ntxId || utils.randomString(4); this.ntxId = ntxId || this.constructor.generateNtxId();
this.hoistedNoteId = hoistedNoteId; this.hoistedNoteId = hoistedNoteId;
this.mainNtxId = mainNtxId; this.mainNtxId = mainNtxId;
this.resetViewScope(); this.resetViewScope();
} }
static generateNtxId() {
return utils.randomString(6);
}
setEmpty() { setEmpty() {
this.notePath = null; this.notePath = null;
this.noteId = null; this.noteId = null;
@ -57,9 +61,8 @@ class NoteContext extends Component {
utils.closeActiveDialog(); utils.closeActiveDialog();
this.notePath = resolvedNotePath; this.notePath = resolvedNotePath;
({noteId: this.noteId, parentNoteId: this.parentNoteId} = treeService.getNoteIdAndParentIdFromNotePath(resolvedNotePath));
this.viewScope = opts.viewScope; this.viewScope = opts.viewScope;
({noteId: this.noteId, parentNoteId: this.parentNoteId} = treeService.getNoteIdAndParentIdFromNotePath(resolvedNotePath));
this.saveToRecentNotes(resolvedNotePath); this.saveToRecentNotes(resolvedNotePath);
@ -298,6 +301,29 @@ class NoteContext extends Component {
// this is reset after navigating to a different note // this is reset after navigating to a different note
this.viewScope = {}; 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; export default NoteContext;

View File

@ -8,6 +8,7 @@ import utils from "../services/utils.js";
import NoteContext from "./note_context.js"; import NoteContext from "./note_context.js";
import appContext from "./app_context.js"; import appContext from "./app_context.js";
import Mutex from "../utils/mutex.js"; import Mutex from "../utils/mutex.js";
import linkService from "../services/link.js";
export default class TabManager extends Component { export default class TabManager extends Component {
constructor() { constructor() {
@ -53,45 +54,44 @@ export default class TabManager extends Component {
? (options.getJson('openTabs') || []) ? (options.getJson('openTabs') || [])
: []; : [];
let filteredTabs = [];
// preload all notes at once // preload all notes at once
await froca.getNotes([ await froca.getNotes([
...tabsToOpen.map(tab => treeService.getNoteIdFromNotePath(tab.notePath)), ...tabsToOpen.map(tab => treeService.getNoteIdFromNotePath(tab.notePath)),
...tabsToOpen.map(tab => tab.hoistedNoteId), ...tabsToOpen.map(tab => tab.hoistedNoteId),
], true); ], true);
for (const openTab of tabsToOpen) { const filteredTabs = tabsToOpen.filter(openTab => {
if (openTab.notePath && !(treeService.getNoteIdFromNotePath(openTab.notePath) in froca.notes)) { 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 // note doesn't exist so don't try to open tab for it
continue; return false;
} }
if (!(openTab.hoistedNoteId in froca.notes)) { if (!(openTab.hoistedNoteId in froca.notes)) {
openTab.hoistedNoteId = 'root'; openTab.hoistedNoteId = 'root';
} }
filteredTabs.push(openTab); return true;
} });
if (utils.isMobile()) {
// mobile frontend doesn't have tabs so show only the active tab
filteredTabs = filteredTabs.filter(tab => tab.active);
}
// resolve before opened tabs can change this // resolve before opened tabs can change this
const [notePathInUrl, ntxIdInUrl] = treeService.getHashValueFromAddress(); const parsedFromUrl = treeService.parseNavigationStateFromAddress();
if (filteredTabs.length === 0) { if (filteredTabs.length === 0) {
filteredTabs.push({ parsedFromUrl.ntxId = parsedFromUrl.ntxId || NoteContext.generateNtxId(); // generate already here, so that we later know which one to activate
notePath: notePathInUrl || 'root',
active: true,
hoistedNoteId: glob.extraHoistedNoteId || 'root',
viewScope: glob.extraViewScope || {}
});
}
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; 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 // 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) // (useful, for e.g. opening clipped notes from clipper or opening link in an extra window)
if (notePathInUrl) { if (parsedFromUrl.notePath) {
await appContext.tabManager.switchToNoteContext(ntxIdInUrl, notePathInUrl); await appContext.tabManager.switchToNoteContext(
parsedFromUrl.ntxId,
parsedFromUrl.notePath,
parsedFromUrl.viewScope,
parsedFromUrl.hoistedNoteId
);
} }
} }
catch (e) { catch (e) {
@ -123,28 +128,41 @@ export default class TabManager extends Component {
noteSwitchedEvent({noteContext}) { noteSwitchedEvent({noteContext}) {
if (noteContext.isActive()) { if (noteContext.isActive()) {
this.setCurrentNotePathToHash(); this.setCurrentNavigationStateToHash();
} }
this.tabsUpdate.scheduleUpdate(); this.tabsUpdate.scheduleUpdate();
} }
setCurrentNotePathToHash() { setCurrentNavigationStateToHash() {
const activeNoteContext = this.getActiveContext(); const calculatedHash = this.calculateHash();
if (window.history.length === 0 // first history entry
|| (activeNoteContext && activeNoteContext.notePath !== treeService.getHashValueFromAddress()[0])) {
const url = `#${activeNoteContext.notePath || ""}-${activeNoteContext.ntxId}`;
// 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 // 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.updateDocumentTitle(activeNoteContext);
this.triggerEvent('activeNoteChanged'); // trigger this even in on popstate event 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[]} */ /** @returns {NoteContext[]} */
getNoteContexts() { getNoteContexts() {
return this.noteContexts; return this.noteContexts;
@ -212,14 +230,18 @@ export default class TabManager extends Component {
return activeNote ? activeNote.mime : null; 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) const noteContext = this.noteContexts.find(nc => nc.ntxId === ntxId)
|| await this.openEmptyTab(); || await this.openEmptyTab();
await this.activateNoteContext(noteContext.ntxId); await this.activateNoteContext(noteContext.ntxId);
if (hoistedNoteId) {
await noteContext.setHoistedNoteId(hoistedNoteId);
}
if (notePath) { 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.tabsUpdate.scheduleUpdate();
this.setCurrentNotePathToHash(); this.setCurrentNavigationStateToHash();
} }
/** /**
@ -564,21 +586,21 @@ export default class TabManager extends Component {
this.tabsUpdate.scheduleUpdate(); this.tabsUpdate.scheduleUpdate();
} }
updateDocumentTitle(activeNoteContext) { async updateDocumentTitle(activeNoteContext) {
const titleFragments = [ const titleFragments = [
// it helps to navigate in history if note title is included in the title // it helps to navigate in history if note title is included in the title
activeNoteContext.note?.title, await activeNoteContext.getNavigationTitle(),
"Trilium Notes" "Trilium Notes"
].filter(Boolean); ].filter(Boolean);
document.title = titleFragments.join(" - "); document.title = titleFragments.join(" - ");
} }
entitiesReloadedEvent({loadResults}) { async entitiesReloadedEvent({loadResults}) {
const activeContext = this.getActiveContext(); const activeContext = this.getActiveContext();
if (activeContext && loadResults.isNoteReloaded(activeContext.noteId)) { if (activeContext && loadResults.isNoteReloaded(activeContext.noteId)) {
this.updateDocumentTitle(activeContext); await this.updateDocumentTitle(activeContext);
} }
} }
} }

View File

@ -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) { function goToLink(evt) {
const $link = $(evt.target).closest("a,.block-link"); const $link = $(evt.target).closest("a,.block-link");
const hrefLink = $link.attr('href'); const hrefLink = $link.attr('href');
@ -223,5 +254,6 @@ export default {
createNoteLink, createNoteLink,
goToLink, goToLink,
loadReferenceLinkTitle, loadReferenceLinkTitle,
parseNotePathAndScope parseNotePathAndScope,
calculateHash
}; };

View File

@ -23,8 +23,8 @@ async function resolveNotePath(notePath, hoistedNoteId = 'root') {
async function resolveNotePathToSegments(notePath, hoistedNoteId = 'root', logErrors = true) { async function resolveNotePathToSegments(notePath, hoistedNoteId = 'root', logErrors = true) {
utils.assertArguments(notePath); utils.assertArguments(notePath);
// we might get notePath with the ntxId suffix, remove it if present // we might get notePath with the params suffix, remove it if present
notePath = notePath.split("-")[0].trim(); notePath = notePath.split("?")[0].trim();
if (notePath.length === 0) { if (notePath.length === 0) {
return; return;
@ -159,8 +159,8 @@ function getNoteIdFromNotePath(notePath) {
const lastSegment = path[path.length - 1]; const lastSegment = path[path.length - 1];
// path could have also ntxId suffix // path could have also params suffix
return lastSegment.split("-")[0]; return lastSegment.split("?")[0];
} }
async function getBranchIdFromNotePath(notePath) { async function getBranchIdFromNotePath(notePath) {
@ -185,8 +185,8 @@ function getNoteIdAndParentIdFromNotePath(notePath) {
const lastSegment = path[path.length - 1]; const lastSegment = path[path.length - 1];
// path could have also ntxId suffix // path could have also params suffix
noteId = lastSegment.split("-")[0]; noteId = lastSegment.split("?")[0];
if (path.length > 1) { if (path.length > 1) {
parentNoteId = path[path.length - 2]; parentNoteId = path[path.length - 2];
@ -297,14 +297,44 @@ async function getNoteTitleWithPathAsSuffix(notePath) {
return $titleWithPath; return $titleWithPath;
} }
function getHashValueFromAddress() { function parseNavigationStateFromAddress() {
const str = document.location.hash ? document.location.hash.substr(1) : ""; // strip initial # 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() { function isNotePathInAddress() {
const [notePath, ntxId] = getHashValueFromAddress(); const {notePath, ntxId} = parseNavigationStateFromAddress();
return notePath.startsWith("root") return notePath.startsWith("root")
// empty string is for empty/uninitialized tab // empty string is for empty/uninitialized tab
@ -338,7 +368,7 @@ export default {
getNoteTitle, getNoteTitle,
getNotePathTitle, getNotePathTitle,
getNoteTitleWithPathAsSuffix, getNoteTitleWithPathAsSuffix,
getHashValueFromAddress, parseNavigationStateFromAddress,
isNotePathInAddress, isNotePathInAddress,
parseNotePath, parseNotePath,
isNotePathInHiddenSubtree isNotePathInHiddenSubtree

View File

@ -55,6 +55,7 @@ export default class HistoryNavigationButton extends ButtonFromNoteWidget {
for (const idx in this.webContents.history) { for (const idx in this.webContents.history) {
const url = this.webContents.history[idx]; const url = this.webContents.history[idx];
const [_, notePathWithTab] = url.split('#'); const [_, notePathWithTab] = url.split('#');
// broken: use treeService.parseNavigationStateFromAddress();
const [notePath, ntxId] = notePathWithTab.split('-'); const [notePath, ntxId] = notePathWithTab.split('-');
const title = await treeService.getNotePathTitle(notePath); const title = await treeService.getNotePathTitle(notePath);

View File

@ -70,37 +70,20 @@ export default class NoteTitleWidget extends NoteContextAwareWidget {
} }
async refreshWithNote(note) { async refreshWithNote(note) {
this.$noteTitle.val(await this.getTitleText(note)); const isReadOnly = (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable())
this.$noteTitle.prop("readonly",
(note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable())
|| ['_lbRoot', '_lbAvailableLaunchers', '_lbVisibleLaunchers'].includes(note.noteId) || ['_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); 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 */ /** @param {FNote} note */
setProtectedStatus(note) { setProtectedStatus(note) {
this.$noteTitle.toggleClass("protected", !!note.isProtected); this.$noteTitle.toggleClass("protected", !!note.isProtected);

View File

@ -618,7 +618,7 @@ export default class TabRowWidget extends BasicWidget {
} }
/** @param {NoteContext} noteContext */ /** @param {NoteContext} noteContext */
updateTab($tab, noteContext) { async updateTab($tab, noteContext) {
if (!$tab.length) { if (!$tab.length) {
return; return;
} }
@ -652,11 +652,7 @@ export default class TabRowWidget extends BasicWidget {
return; return;
} }
const viewMode = noteContext.viewScope?.viewMode; const title = await noteContext.getNavigationTitle();
const title = (viewMode && viewMode !== 'default')
? `${viewMode}: ${note.title}`
: note.title;
this.updateTitle($tab, title); this.updateTitle($tab, title);
$tab.addClass(note.getCssClass()); $tab.addClass(note.getCssClass());

View File

@ -35,9 +35,6 @@ function index(req, res) {
appCssNoteIds: getAppCssNoteIds(), appCssNoteIds: getAppCssNoteIds(),
isDev: env.isDev(), isDev: env.isDev(),
isMainWindow: !req.query.extraWindow, 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(), isProtectedSessionAvailable: protectedSessionService.isProtectedSessionAvailable(),
maxContentWidth: parseInt(options.maxContentWidth), maxContentWidth: parseInt(options.maxContentWidth),
triliumVersion: packageJson.version, triliumVersion: packageJson.version,

View File

@ -15,7 +15,7 @@ let mainWindow;
/** @type {Electron.BrowserWindow} */ /** @type {Electron.BrowserWindow} */
let setupWindow; let setupWindow;
async function createExtraWindow(notePath, hoistedNoteId = 'root', viewScope = {}) { async function createExtraWindow(extraWindowHash) {
const spellcheckEnabled = optionService.getOptionBool('spellCheckEnabled'); const spellcheckEnabled = optionService.getOptionBool('spellCheckEnabled');
const {BrowserWindow} = require('electron'); const {BrowserWindow} = require('electron');
@ -35,13 +35,13 @@ async function createExtraWindow(notePath, hoistedNoteId = 'root', viewScope = {
}); });
win.setMenuBarVisibility(false); 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); configureWebContents(win.webContents, spellcheckEnabled);
} }
ipcMain.on('create-extra-window', (event, arg) => { ipcMain.on('create-extra-window', (event, arg) => {
createExtraWindow(arg.notePath, arg.hoistedNoteId, arg.viewScope); createExtraWindow(arg.extraWindowHash);
}); });
async function createMainWindow(app) { async function createMainWindow(app) {

View File

@ -32,8 +32,6 @@
isDev: <%= isDev %>, isDev: <%= isDev %>,
appCssNoteIds: <%- JSON.stringify(appCssNoteIds) %>, appCssNoteIds: <%- JSON.stringify(appCssNoteIds) %>,
isMainWindow: <%= isMainWindow %>, isMainWindow: <%= isMainWindow %>,
extraHoistedNoteId: '<%= extraHoistedNoteId %>',
extraViewScope: <%- extraViewScope %>,
isProtectedSessionAvailable: <%= isProtectedSessionAvailable %>, isProtectedSessionAvailable: <%= isProtectedSessionAvailable %>,
triliumVersion: "<%= triliumVersion %>", triliumVersion: "<%= triliumVersion %>",
assetPath: "<%= assetPath %>", assetPath: "<%= assetPath %>",