trilium/src/public/javascripts/services/note_detail.js
2019-08-26 19:49:19 +02:00

574 lines
15 KiB
JavaScript

import treeService from './tree.js';
import TabContext from './tab_context.js';
import server from './server.js';
import messagingService from "./messaging.js";
import treeCache from "./tree_cache.js";
import NoteFull from "../entities/note_full.js";
import bundleService from "./bundle.js";
import utils from "./utils.js";
import contextMenuService from "./context_menu.js";
import treeUtils from "./tree_utils.js";
import tabRow from "./tab_row.js";
const $tabContentsContainer = $("#note-tab-container");
const $savedIndicator = $(".saved-indicator");
let detailLoadedListeners = [];
async function reload() {
// no saving here
await loadNoteDetail(getActiveTabContext().notePath);
}
async function reloadTab(tabContext) {
if (tabContext.note) {
const note = await loadNote(tabContext.note.noteId);
await loadNoteDetailToContext(tabContext, note, tabContext.notePath);
}
}
async function reloadAllTabs() {
for (const tabContext of tabContexts) {
await reloadTab(tabContext);
}
}
async function openInTab(notePath) {
await loadNoteDetail(notePath, { newTab: true });
}
async function switchToNote(notePath) {
await saveNotesIfChanged();
await loadNoteDetail(notePath);
openTabsChanged();
}
function onNoteChange(func) {
return getActiveTabContext().getComponent().onNoteChange(func);
}
async function saveNotesIfChanged() {
for (const ctx of tabContexts) {
await ctx.saveNoteIfChanged();
}
// make sure indicator is visible in a case there was some race condition.
$savedIndicator.fadeIn();
}
/** @type {TabContext[]} */
let tabContexts = [];
function getActiveEditor() {
const activeTabContext = getActiveTabContext();
if (activeTabContext && activeTabContext.note && activeTabContext.note.type === 'text') {
return activeTabContext.getComponent().getEditor();
}
else {
return null;
}
}
async function activateOrOpenNote(noteId) {
for (const tabContext of tabContexts) {
if (tabContext.note && tabContext.note.noteId === noteId) {
await tabContext.activate();
return;
}
}
// if no tab with this note has been found we'll create new tab
await loadNoteDetail(noteId, {
newTab: true,
activate: true
});
}
function getTabContexts() {
return tabContexts;
}
/** @returns {TabContext} */
function getActiveTabContext() {
const activeTabEl = tabRow.activeTabEl;
if (!activeTabEl) {
return null;
}
const tabId = activeTabEl.getAttribute('data-tab-id');
return tabContexts.find(tc => tc.tabId === tabId);
}
/** @return {NoteFull} */
function getActiveNote() {
const activeContext = getActiveTabContext();
return activeContext ? activeContext.note : null;
}
function getActiveNoteId() {
const activeNote = getActiveNote();
return activeNote ? activeNote.noteId : null;
}
function getActiveNoteType() {
const activeNote = getActiveNote();
return activeNote ? activeNote.type : null;
}
async function switchToTab(tabId, notePath) {
const tabContext = tabContexts.find(tc => tc.tabId === tabId);
if (!tabContext) {
await loadNoteDetail(notePath, {
newTab: true,
activate: true
});
} else {
await tabContext.activate();
if (notePath && tabContext.notePath !== notePath) {
await treeService.activateNote(notePath);
}
}
}
async function showTab(tabId) {
for (const ctx of tabContexts) {
if (ctx.tabId === tabId) {
ctx.show();
}
else {
ctx.hide();
}
}
const oldActiveNode = treeService.getActiveNode();
if (oldActiveNode) {
oldActiveNode.setActive(false);
}
treeService.clearSelectedNodes();
const newActiveTabContext = getActiveTabContext();
if (newActiveTabContext && newActiveTabContext.notePath) {
const newActiveNode = await treeService.getNodeFromPath(newActiveTabContext.notePath);
if (newActiveNode && newActiveNode.isVisible()) {
newActiveNode.setActive(true, {noEvents: true});
}
}
}
async function renderComponent(ctx) {
for (const componentType in ctx.components) {
if (componentType !== ctx.note.type) {
ctx.components[componentType].cleanup();
}
}
ctx.$noteDetailComponents.hide();
ctx.$noteTitle.show(); // this can be hidden by empty detail
ctx.$noteTitle.removeAttr("readonly"); // this can be set by protected session service
await ctx.getComponent().render();
}
/**
* @param {TabContext} ctx
* @param {NoteFull} note
*/
async function loadNoteDetailToContext(ctx, note, notePath) {
await ctx.setNote(note, notePath);
openTabsChanged();
if (utils.isDesktop()) {
// needs to happen after loading the note itself because it references active noteId
ctx.attributes.refreshAttributes();
} else {
// mobile usually doesn't need attributes so we just invalidate
ctx.attributes.invalidateAttributes();
}
ctx.noteChangeDisabled = true;
try {
ctx.$noteTitle.val(ctx.note.title);
if (utils.isDesktop()) {
ctx.noteType.update();
}
await renderComponent(ctx);
} finally {
ctx.noteChangeDisabled = false;
}
treeService.setBranchBackgroundBasedOnProtectedStatus(note.noteId);
// after loading new note make sure editor is scrolled to the top
ctx.getComponent().scrollToTop();
fireDetailLoaded();
ctx.$scriptArea.empty();
await bundleService.executeRelationBundles(ctx.note, 'runOnNoteView', ctx);
if (utils.isDesktop()) {
await ctx.attributes.showAttributes();
await ctx.showChildrenOverview();
}
}
async function loadNoteDetail(origNotePath, options = {}) {
const newTab = !!options.newTab;
const activate = !!options.activate;
const notePath = await treeService.resolveNotePath(origNotePath);
if (!notePath) {
console.error(`Cannot resolve note path ${origNotePath}`);
// fallback to display something
if (tabContexts.length === 0) {
await openEmptyTab();
}
return;
}
const noteId = treeUtils.getNoteIdFromNotePath(notePath);
const loadedNote = await loadNote(noteId);
let ctx;
if (!getActiveTabContext() || newTab) {
// if it's a new tab explicitly by user then it's in background
ctx = new TabContext(tabRow, options.state);
tabContexts.push(ctx);
}
else {
ctx = getActiveTabContext();
}
// we will try to render the new note only if it's still the active one in the tree
// this is useful when user quickly switches notes (by e.g. holding down arrow) so that we don't
// try to render all those loaded notes one after each other. This only guarantees that correct note
// will be displayed independent of timing
const currentTreeNode = treeService.getActiveNode();
if (!newTab && currentTreeNode && currentTreeNode.data.noteId !== loadedNote.noteId) {
return;
}
await loadNoteDetailToContext(ctx, loadedNote, notePath);
if (activate) {
// will also trigger showTab via event
await tabRow.activateTab(ctx.$tab[0]);
}
}
async function loadNote(noteId) {
const row = await server.get('notes/' + noteId);
return new NoteFull(treeCache, row);
}
async function filterTabs(noteId) {
for (const tc of tabContexts) {
if (tc.notePath && !tc.notePath.split("/").includes(noteId)) {
await tabRow.removeTab(tc.$tab[0]);
}
}
if (tabContexts.length === 0) {
await loadNoteDetail(noteId, {
newTab: true,
activate: true
});
}
await saveOpenTabs();
}
async function noteDeleted(noteId) {
for (const tc of tabContexts) {
if (tc.notePath && tc.notePath.split("/").includes(noteId)) {
await tabRow.removeTab(tc.$tab[0]);
}
}
}
async function refreshTabs(sourceTabId, noteId) {
for (const tc of tabContexts) {
if (tc.noteId === noteId && tc.tabId !== sourceTabId) {
await reloadTab(tc);
}
}
}
function focusOnTitle() {
getActiveTabContext().$noteTitle.focus();
}
function focusAndSelectTitle() {
getActiveTabContext().$noteTitle.focus().select();
}
/**
* Since detail loading may take some time and user might just browse through the notes using UP-DOWN keys,
* we intentionally decouple activation of the note in the tree and full load of the note so just avaiting on
* fancytree's activate() won't wait for the full load.
*
* This causes an issue where in some cases you want to do some action after detail is loaded. For this reason
* we provide the listeners here which will be triggered after the detail is loaded and if the loaded note
* is the one registered in the listener.
*/
function addDetailLoadedListener(noteId, callback) {
detailLoadedListeners.push({ noteId, callback });
}
function fireDetailLoaded() {
for (const {noteId, callback} of detailLoadedListeners) {
if (noteId === getActiveNoteId()) {
callback();
}
}
// all the listeners are one time only
detailLoadedListeners = [];
}
messagingService.subscribeToOutsideSyncMessages(syncData => {
const noteIdsToRefresh = new Set();
syncData
.filter(sync => sync.entityName === 'notes')
.forEach(sync => noteIdsToRefresh.add(sync.entityId));
// we need to reload because of promoted attributes
syncData
.filter(sync => sync.entityName === 'attributes')
.forEach(sync => noteIdsToRefresh.add(sync.noteId));
for (const noteId of noteIdsToRefresh) {
refreshTabs(null, noteId);
}
});
messagingService.subscribeToAllSyncMessages(syncData => {
for (const tc of tabContexts) {
tc.syncDataReceived(syncData);
}
});
$tabContentsContainer.on("dragover", e => e.preventDefault());
$tabContentsContainer.on("dragleave", e => e.preventDefault());
$tabContentsContainer.on("drop", async e => {
const activeNote = getActiveNote();
if (!activeNote) {
return;
}
const importDialog = await import("../dialogs/import.js");
importDialog.uploadFiles(activeNote.noteId, e.originalEvent.dataTransfer.files, {
safeImport: true,
shrinkImages: true,
textImportedAsText: true,
codeImportedAsCode: true,
explodeArchives: true
});
});
async function openEmptyTab(render = true) {
const ctx = new TabContext(tabRow);
tabContexts.push(ctx);
if (render) {
await renderComponent(ctx);
}
await tabRow.activateTab(ctx.$tab[0]);
}
tabRow.addListener('newTab', openEmptyTab);
tabRow.addListener('activeTabChange', async ({ detail }) => {
const tabId = detail.tabEl.getAttribute('data-tab-id');
await showTab(tabId);
console.log(`Activated tab ${tabId}`);
});
tabRow.addListener('tabRemove', async ({ detail }) => {
const tabId = detail.tabEl.getAttribute('data-tab-id');
const tabContextToDelete = tabContexts.find(nc => nc.tabId === tabId);
if (tabContextToDelete) {
// sometimes there are orphan autocompletes after closing the tab
tabContextToDelete.closeAutocomplete();
await tabContextToDelete.saveNoteIfChanged();
tabContextToDelete.$tabContent.remove();
}
tabContexts = tabContexts.filter(nc => nc.tabId !== tabId);
console.log(`Removed tab ${tabId}`);
if (tabContexts.length === 0) {
openEmptyTab();
}
});
$(tabRow.el).on('contextmenu', '.note-tab', e => {
const tab = $(e.target).closest(".note-tab");
contextMenuService.initContextMenu(e, {
getContextMenuItems: () => {
return [
{title: "Close all tabs", cmd: "removeAllTabs", uiIcon: "empty"},
{title: "Close all tabs except for this", cmd: "removeAllTabsExceptForThis", uiIcon: "empty"}
];
},
selectContextMenuItem: (e, cmd) => {
if (cmd === 'removeAllTabs') {
tabRow.removeAllTabs();
} else if (cmd === 'removeAllTabsExceptForThis') {
tabRow.removeAllTabsExceptForThis(tab[0]);
}
}
});
});
if (utils.isElectron()) {
utils.bindGlobalShortcut('ctrl+t', () => {
openEmptyTab();
});
utils.bindGlobalShortcut('ctrl+w', () => {
if (tabRow.activeTabEl) {
tabRow.removeTab(tabRow.activeTabEl);
}
});
utils.bindGlobalShortcut('ctrl+tab', () => {
const nextTab = tabRow.nextTabEl;
if (nextTab) {
tabRow.activateTab(nextTab);
}
});
utils.bindGlobalShortcut('ctrl+shift+tab', () => {
const prevTab = tabRow.previousTabEl;
if (prevTab) {
tabRow.activateTab(prevTab);
}
});
}
tabRow.addListener('activeTabChange', openTabsChanged);
tabRow.addListener('tabRemove', openTabsChanged);
tabRow.addListener('tabReorder', openTabsChanged);
let tabsChangedTaskId = null;
function clearOpenTabsTask() {
if (tabsChangedTaskId) {
clearTimeout(tabsChangedTaskId);
}
}
function openTabsChanged() {
// we don't want to send too many requests with tab changes so we always schedule task to do this in 1 seconds,
// but if there's any change in between, we cancel the old one and schedule new one
// so effectively we kind of wait until user stopped e.g. quickly switching tabs
clearOpenTabsTask();
tabsChangedTaskId = setTimeout(saveOpenTabs, 1000);
}
async function saveOpenTabs() {
const openTabs = [];
for (const tabEl of tabRow.tabEls) {
const tabId = tabEl.getAttribute('data-tab-id');
const tabContext = tabContexts.find(tc => tc.tabId === tabId);
if (tabContext) {
const tabState = tabContext.getTabState();
if (tabState) {
openTabs.push(tabState);
}
}
}
await server.put('options', {
openTabs: JSON.stringify(openTabs)
});
}
function noteChanged() {
const activeTabContext = getActiveTabContext();
if (activeTabContext) {
activeTabContext.noteChanged();
}
}
// this makes sure that when user e.g. reloads the page or navigates away from the page, the note's content is saved
// this sends the request asynchronously and doesn't wait for result
$(window).on('beforeunload', () => { saveNotesIfChanged(); }); // don't convert to short form, handler doesn't like returned promise
setInterval(saveNotesIfChanged, 3000);
export default {
reload,
reloadAllTabs,
openInTab,
switchToNote,
loadNote,
loadNoteDetail,
getActiveNote,
getActiveNoteType,
getActiveNoteId,
focusOnTitle,
focusAndSelectTitle,
saveNotesIfChanged,
onNoteChange,
addDetailLoadedListener,
switchToTab,
getTabContexts,
getActiveTabContext,
getActiveEditor,
activateOrOpenNote,
clearOpenTabsTask,
filterTabs,
openEmptyTab,
noteDeleted,
refreshTabs,
noteChanged,
openTabsChanged
};