import hoistedNoteService from "../services/hoisted_note.js"; import treeService from "../services/tree.js"; import utils from "../services/utils.js"; import contextMenu from "../services/context_menu.js"; import froca from "../services/froca.js"; import branchService from "../services/branches.js"; import ws from "../services/ws.js"; import TabAwareWidget from "./tab_aware_widget.js"; import server from "../services/server.js"; import noteCreateService from "../services/note_create.js"; import toastService from "../services/toast.js"; import appContext from "../services/app_context.js"; import keyboardActionsService from "../services/keyboard_actions.js"; import clipboard from "../services/clipboard.js"; import protectedSessionService from "../services/protected_session.js"; import syncService from "../services/sync.js"; import options from "../services/options.js"; import protectedSessionHolder from "../services/protected_session_holder.js"; const TPL = `

`; const MAX_SEARCH_RESULTS_IN_TREE = 100; export default class NoteTreeWidget extends TabAwareWidget { constructor(treeName) { super(); this.treeName = treeName; } doRender() { this.$widget = $(TPL); this.$tree = this.$widget.find('.tree'); this.$tree.on("mousedown", ".unhoist-button", () => hoistedNoteService.unhoist()); this.$tree.on("mousedown", ".refresh-search-button", e => this.refreshSearch(e)); this.$tree.on("mousedown", ".add-note-button", e => { const node = $.ui.fancytree.getNode(e); const parentNotePath = treeService.getNotePath(node); noteCreateService.createNote(parentNotePath, { isProtected: node.data.isProtected }); }); this.$tree.on("mousedown", ".enter-workspace-button", e => { const node = $.ui.fancytree.getNode(e); this.triggerCommand('hoistNote', {noteId: node.data.noteId}); }); // fancytree doesn't support middle click so this is a way to support it this.$tree.on('mousedown', '.fancytree-title', e => { if (e.which === 2) { const node = $.ui.fancytree.getNode(e); const notePath = treeService.getNotePath(node); if (notePath) { appContext.tabManager.openTabWithNoteWithHoisting(notePath); } e.stopPropagation(); e.preventDefault(); } }); this.$treeSettingsPopup = this.$widget.find('.tree-settings-popup'); this.$hideArchivedNotesCheckbox = this.$treeSettingsPopup.find('.hide-archived-notes'); this.$hideIncludedImages = this.$treeSettingsPopup.find('.hide-included-images'); this.$autoCollapseNoteTree = this.$treeSettingsPopup.find('.auto-collapse-note-tree'); this.$treeSettingsButton = this.$widget.find('.tree-settings-button'); this.$treeSettingsButton.on("click", e => { if (this.$treeSettingsPopup.is(":visible")) { this.$treeSettingsPopup.hide(); return; } this.$hideArchivedNotesCheckbox.prop("checked", this.hideArchivedNotes); this.$hideIncludedImages.prop("checked", this.hideIncludedImages); this.$autoCollapseNoteTree.prop("checked", this.autoCollapseNoteTree); let top = this.$treeSettingsButton[0].offsetTop; let left = this.$treeSettingsButton[0].offsetLeft; top -= this.$treeSettingsPopup.outerHeight() + 10; left += this.$treeSettingsButton.outerWidth() - this.$treeSettingsPopup.outerWidth(); if (left < 0) { left = 0; } this.$treeSettingsPopup.css({ display: "block", top: top, left: left }).addClass("show"); return false; }); this.$treeSettingsPopup.on("click", e => { e.stopPropagation(); }); $(document).on('click', () => this.$treeSettingsPopup.hide()); this.$saveTreeSettingsButton = this.$treeSettingsPopup.find('.save-tree-settings-button'); this.$saveTreeSettingsButton.on('click', async () => { await this.setHideArchivedNotes(this.$hideArchivedNotesCheckbox.prop("checked")); await this.setHideIncludedImages(this.$hideIncludedImages.prop("checked")); await this.setAutoCollapseNoteTree(this.$autoCollapseNoteTree.prop("checked")); this.$treeSettingsPopup.hide(); this.reloadTreeFromCache(); }); this.initFancyTree(); this.setupNoteTitleTooltip(); } setupNoteTitleTooltip() { // the following will dynamically set tree item's tooltip if the whole item's text is not currently visible // if the whole text is visible then no tooltip is show since that's unnecessarily distracting // see https://github.com/zadam/trilium/pull/1120 for discussion // code inspired by https://gist.github.com/jtsternberg/c272d7de5b967cec2d3d const isEnclosing = ($container, $sub) => { const conOffset = $container.offset(); const conDistanceFromTop = conOffset.top + $container.outerHeight(true); const conDistanceFromLeft = conOffset.left + $container.outerWidth(true); const subOffset = $sub.offset(); const subDistanceFromTop = subOffset.top + $sub.outerHeight(true); const subDistanceFromLeft = subOffset.left + $sub.outerWidth(true); return conDistanceFromTop > subDistanceFromTop && conOffset.top < subOffset.top && conDistanceFromLeft > subDistanceFromLeft && conOffset.left < subOffset.left; }; this.$tree.on("mouseenter", "span.fancytree-title", e => { e.currentTarget.title = isEnclosing(this.$tree, $(e.currentTarget)) ? "" : e.currentTarget.innerText; }); } get hideArchivedNotes() { return options.is("hideArchivedNotes_" + this.treeName); } async setHideArchivedNotes(val) { await options.save("hideArchivedNotes_" + this.treeName, val.toString()); } get hideIncludedImages() { return options.is("hideIncludedImages_" + this.treeName); } async setHideIncludedImages(val) { await options.save("hideIncludedImages_" + this.treeName, val.toString()); } get autoCollapseNoteTree() { return options.is("autoCollapseNoteTree"); } async setAutoCollapseNoteTree(val) { await options.save("autoCollapseNoteTree", val.toString()); } initFancyTree() { const treeData = [this.prepareRootNode()]; this.$tree.fancytree({ titlesTabbable: true, keyboard: true, extensions: ["dnd5", "clones", "filter"], source: treeData, scrollOfs: { top: 100, bottom: 100 }, scrollParent: this.$tree, minExpandLevel: 2, // root can't be collapsed click: (event, data) => { this.activityDetected(); const targetType = data.targetType; const node = data.node; if (targetType === 'title' || targetType === 'icon') { if (event.shiftKey) { node.setSelected(!node.isSelected()); node.setFocus(true); } else if (event.ctrlKey) { const notePath = treeService.getNotePath(node); appContext.tabManager.openTabWithNoteWithHoisting(notePath); } else if (data.node.isActive()) { // this is important for single column mobile view, otherwise it's not possible to see again previously displayed note this.tree.reactivate(true); } else { node.setActive(); } return false; } }, activate: async (event, data) => { // click event won't propagate so let's close context menu manually contextMenu.hide(); this.clearSelectedNodes(); const notePath = treeService.getNotePath(data.node); const activeTabContext = appContext.tabManager.getActiveTabContext(); await activeTabContext.setNote(notePath); if (utils.isMobile()) { this.triggerCommand('setActiveScreen', {screen: 'detail'}); } }, expand: (event, data) => this.setExpanded(data.node.data.branchId, true), collapse: (event, data) => this.setExpanded(data.node.data.branchId, false), filter: { counter: false, mode: "hide", autoExpand: true }, dnd5: { autoExpandMS: 600, preventLazyParents: false, dragStart: (node, data) => { const notes = this.getSelectedOrActiveNodes(node).map(node => ({ noteId: node.data.noteId, branchId: node.data.branchId, title: node.title })); data.dataTransfer.setData("text", JSON.stringify(notes)); return true; // allow dragging to start }, dragEnter: (node, data) => node.data.noteType !== 'search', dragDrop: async (node, data) => { if ((data.hitMode === 'over' && node.data.noteType === 'search') || (['after', 'before'].includes(data.hitMode) && (node.data.noteId === hoistedNoteService.getHoistedNoteId() || node.getParent().data.noteType === 'search'))) { const infoDialog = await import('../dialogs/info.js'); await infoDialog.info("Dropping notes into this location is not allowed."); return; } const dataTransfer = data.dataTransfer; if (dataTransfer && dataTransfer.files && dataTransfer.files.length > 0) { const files = [...dataTransfer.files]; // chrome has issue that dataTransfer.files empties after async operation const importService = await import('../services/import.js'); importService.uploadFiles(node.data.noteId, files, { safeImport: true, shrinkImages: true, textImportedAsText: true, codeImportedAsCode: true, explodeArchives: true, replaceUnderscoresWithSpaces: true }); } else { const jsonStr = dataTransfer.getData("text"); let notes = null; try { notes = JSON.parse(jsonStr); } catch (e) { logError(`Cannot parse ${jsonStr} into notes for drop`); return; } // This function MUST be defined to enable dropping of items on the tree. // data.hitMode is 'before', 'after', or 'over'. const selectedBranchIds = notes.map(note => note.branchId); if (data.hitMode === "before") { branchService.moveBeforeBranch(selectedBranchIds, node.data.branchId); } else if (data.hitMode === "after") { branchService.moveAfterBranch(selectedBranchIds, node.data.branchId); } else if (data.hitMode === "over") { branchService.moveToParentNote(selectedBranchIds, node.data.branchId); } else { throw new Error("Unknown hitMode=" + data.hitMode); } } } }, lazyLoad: (event, data) => { const {noteId, noteType} = data.node.data; if (noteType === 'search') { const notePath = treeService.getNotePath(data.node.getParent()); // this is a search cycle (search note is a descendant of its own search result) if (notePath.includes(noteId)) { data.result = []; return; } data.result = froca.loadSearchNote(noteId).then(() => { const note = froca.getNoteFromCache(noteId); let childNoteIds = note.getChildNoteIds(); if (note.type === 'search' && childNoteIds.length > MAX_SEARCH_RESULTS_IN_TREE) { childNoteIds = childNoteIds.slice(0, MAX_SEARCH_RESULTS_IN_TREE); } return froca.getNotes(childNoteIds); }).then(() => { const note = froca.getNoteFromCache(noteId); return this.prepareChildren(note); }); } else { data.result = froca.loadSubTree(noteId).then(note => this.prepareChildren(note)); } }, clones: { highlightActiveClones: true }, enhanceTitle: async function (event, data) { const node = data.node; if (!node.data.noteId) { // if there's "non-note" node, then don't enhance // this can happen for e.g. "Load error!" node return; } const note = await froca.getNote(node.data.noteId); const activeTabContext = appContext.tabManager.getActiveTabContext(); const $span = $(node.span); $span.find('.tree-item-button').remove(); const isHoistedNote = activeTabContext && activeTabContext.hoistedNoteId === note.noteId && note.noteId !== 'root'; if (isHoistedNote) { const $unhoistButton = $(''); $unhoistButton.on('click', () => alert("bebe")); $span.append($unhoistButton); } if (note.hasLabel('workspace') && !isHoistedNote) { const $enterWorkspaceButton = $(''); $span.append($enterWorkspaceButton); } if (note.type === 'search') { const $refreshSearchButton = $(''); $span.append($refreshSearchButton); } if (note.type !== 'search') { const $createChildNoteButton = $(''); $span.append($createChildNoteButton); } }, // this is done to automatically lazy load all expanded notes after tree load loadChildren: (event, data) => { data.node.visit((subNode) => { // Load all lazy/unloaded child nodes // (which will trigger `loadChildren` recursively) if (subNode.isUndefined() && subNode.isExpanded()) { subNode.load(); } }); } }); if (!utils.isMobile()) { this.getHotKeys().then(hotKeys => { for (const key in hotKeys) { const handler = hotKeys[key]; $(this.tree.$container).on('keydown', null, key, evt => { const node = this.tree.getActiveNode(); return handler(node, evt); // return false from the handler will stop default handling. }); } }); } this.$tree.on('contextmenu', '.fancytree-node', e => { const node = $.ui.fancytree.getNode(e); import("../services/tree_context_menu.js").then(({default: TreeContextMenu}) => { const treeContextMenu = new TreeContextMenu(this, node); treeContextMenu.show(e); }); return false; // blocks default browser right click menu }); this.tree = $.ui.fancytree.getTree(this.$tree); } prepareRootNode() { return this.prepareNode(froca.getBranch('root')); } /** * @param {NoteShort} parentNote */ prepareChildren(parentNote) { utils.assertArguments(parentNote); const noteList = []; const hideArchivedNotes = this.hideArchivedNotes; let childBranches = parentNote.getFilteredChildBranches(); if (parentNote.type === 'search' && childBranches.length > MAX_SEARCH_RESULTS_IN_TREE) { childBranches = childBranches.slice(0, MAX_SEARCH_RESULTS_IN_TREE); } for (const branch of childBranches) { if (hideArchivedNotes) { const note = branch.getNoteFromCache(); if (note.hasLabel('archived')) { continue; } } const node = this.prepareNode(branch); noteList.push(node); } return noteList; } updateNode(node) { const note = froca.getNoteFromCache(node.data.noteId); const branch = froca.getBranch(node.data.branchId); if (!note) { console.log(`Node update not possible because note ${node.data.noteId} was not found.`); return; } if (!branch) { console.log(`Node update not possible because branch ${node.data.branchId} was not found.`); return; } const title = (branch.prefix ? (branch.prefix + " - ") : "") + note.title; node.data.isProtected = note.isProtected; node.data.noteType = note.type; node.folder = note.isFolder(); node.icon = note.getIcon(); node.extraClasses = this.getExtraClasses(note); node.title = utils.escapeHtml(title); if (node.isExpanded() !== branch.isExpanded) { node.setExpanded(branch.isExpanded, {noEvents: true, noAnimation: true}); } node.renderTitle(); } /** * @param {Branch} branch */ prepareNode(branch, forceLazy = false) { const note = branch.getNoteFromCache(); if (!note) { throw new Error(`Branch "${branch.branchId}" has no note "${branch.noteId}"`); } const title = (branch.prefix ? (branch.prefix + " - ") : "") + note.title; const isFolder = note.isFolder(); const node = { noteId: note.noteId, parentNoteId: branch.parentNoteId, branchId: branch.branchId, isProtected: note.isProtected, noteType: note.type, title: utils.escapeHtml(title), extraClasses: this.getExtraClasses(note), icon: note.getIcon(isFolder), refKey: note.noteId, lazy: true, folder: isFolder, expanded: branch.isExpanded && note.type !== 'search', key: utils.randomString(12) // this should prevent some "duplicate key" errors }; if (isFolder && node.expanded && !forceLazy) { node.children = this.prepareChildren(note); } return node; } getExtraClasses(note) { utils.assertArguments(note); const extraClasses = []; if (note.isProtected) { extraClasses.push("protected"); } if (note.getParentNoteIds().length > 1) { extraClasses.push("multiple-parents"); } const cssClass = note.getCssClass(); if (cssClass) { extraClasses.push(cssClass); } extraClasses.push(utils.getNoteTypeClass(note.type)); if (note.mime) { // some notes should not have mime type (e.g. render) extraClasses.push(utils.getMimeTypeClass(note.mime)); } if (note.hasLabel('archived')) { extraClasses.push("archived"); } return extraClasses.join(" "); } /** @return {FancytreeNode[]} */ getSelectedNodes(stopOnParents = false) { return this.tree.getSelectedNodes(stopOnParents); } /** @return {FancytreeNode[]} */ getSelectedOrActiveNodes(node = null) { const nodes = this.getSelectedNodes(true); // the node you start dragging should be included even if not selected if (node && !nodes.find(n => n.key === node.key)) { nodes.push(node); } if (nodes.length === 0) { nodes.push(this.getActiveNode()); } return nodes; } async setExpandedStatusForSubtree(node, isExpanded) { if (!node) { const hoistedNoteId = hoistedNoteService.getHoistedNoteId(); node = this.getNodesByNoteId(hoistedNoteId)[0]; } const {branchIds} = await server.put(`branches/${node.data.branchId}/expanded-subtree/${isExpanded ? 1 : 0}`); froca.getBranches(branchIds, true) .forEach(branch => branch.isExpanded = !!isExpanded); await this.batchUpdate(async () => { await node.load(true); if (node.data.noteId !== 'root') { // root is always expanded await node.setExpanded(isExpanded, {noEvents: true, noAnimation: true}); } }); } async expandTree(node = null) { await this.setExpandedStatusForSubtree(node, true); } async collapseTree(node = null) { await this.setExpandedStatusForSubtree(node, false); } collapseTreeEvent() { this.collapseTree(); } /** * @return {FancytreeNode|null} */ getActiveNode() { return this.tree.getActiveNode(); } /** * focused & not active node can happen during multiselection where the node is selected * but not activated (its content is not displayed in the detail) * @return {FancytreeNode|null} */ getFocusedNode() { return this.tree.getFocusNode(); } clearSelectedNodes() { for (const selectedNode of this.getSelectedNodes()) { selectedNode.setSelected(false); } } async scrollToActiveNoteEvent() { const activeContext = appContext.tabManager.getActiveTabContext(); if (activeContext && activeContext.notePath) { this.tree.setFocus(true); const node = await this.expandToNote(activeContext.notePath); if (node) { await node.makeVisible({scrollIntoView: true}); node.setActive(true, {noEvents: true, noFocus: false}); } } } /** @return {FancytreeNode} */ async getNodeFromPath(notePath, expand = false, logErrors = true) { utils.assertArguments(notePath); /** @let {FancytreeNode} */ let parentNode = this.getNodesByNoteId('root')[0]; let resolvedNotePathSegments = await treeService.resolveNotePathToSegments(notePath, this.hoistedNoteId, logErrors); if (!resolvedNotePathSegments) { if (logErrors) { logError("Could not find run path for notePath:", notePath); } return; } resolvedNotePathSegments = resolvedNotePathSegments.slice(1); for (const childNoteId of resolvedNotePathSegments) { // we expand only after hoisted note since before then nodes are not actually present in the tree if (parentNode) { if (!parentNode.isLoaded()) { await parentNode.load(); } if (expand) { await parentNode.setExpanded(true, {noAnimation: true}); // although previous line should set the expanded status, it seems to happen asynchronously // so we need to make sure it is set properly before calling updateNode which uses this flag const branch = froca.getBranch(parentNode.data.branchId); branch.isExpanded = true; } this.updateNode(parentNode); let foundChildNode = this.findChildNode(parentNode, childNoteId); if (!foundChildNode) { // note might be recently created so we'll force reload and try again await parentNode.load(true); foundChildNode = this.findChildNode(parentNode, childNoteId); if (!foundChildNode) { if (logErrors) { // besides real errors this can be also caused by hiding of e.g. included images // these are real notes with real notePath, user can display them in a detail // but they don't have a node in the tree const childNote = await froca.getNote(childNoteId); if (!childNote || childNote.type !== 'image') { ws.logError(`Can't find node for child node of noteId=${childNoteId} for parent of noteId=${parentNode.data.noteId} and hoistedNoteId=${hoistedNoteService.getHoistedNoteId()}, requested path is ${notePath}`); } } return; } } parentNode = foundChildNode; } } return parentNode; } /** @return {FancytreeNode} */ findChildNode(parentNode, childNoteId) { return parentNode.getChildren().find(childNode => childNode.data.noteId === childNoteId); } /** @return {FancytreeNode} */ async expandToNote(notePath, logErrors = true) { return this.getNodeFromPath(notePath, true, logErrors); } /** @return {FancytreeNode[]} */ getNodesByBranchId(branchId) { utils.assertArguments(branchId); const branch = froca.getBranch(branchId); return this.getNodesByNoteId(branch.noteId).filter(node => node.data.branchId === branchId); } /** @return {FancytreeNode[]} */ getNodesByNoteId(noteId) { utils.assertArguments(noteId); const list = this.tree.getNodesByRef(noteId); return list ? list : []; // if no nodes with this refKey are found, fancy tree returns null } isEnabled() { return !!this.tabContext; } async refresh() { this.toggleInt(this.isEnabled()); this.$treeSettingsPopup.hide(); this.activityDetected(); const oldActiveNode = this.getActiveNode(); let oldActiveNodeFocused = false; if (oldActiveNode) { oldActiveNodeFocused = oldActiveNode.hasFocus(); oldActiveNode.setActive(false); oldActiveNode.setFocus(false); } if (this.tabContext && this.tabContext.notePath && !this.tabContext.note.isDeleted) { const newActiveNode = await this.getNodeFromPath(this.tabContext.notePath); if (newActiveNode) { if (!newActiveNode.isVisible()) { await this.expandToNote(this.tabContext.notePath); } newActiveNode.setActive(true, {noEvents: true, noFocus: !oldActiveNodeFocused}); newActiveNode.makeVisible({scrollIntoView: true}); } } this.filterHoistedBranch(); } async refreshSearch(e) { const activeNode = $.ui.fancytree.getNode(e); activeNode.load(true); activeNode.setExpanded(true, {noAnimation: true}); toastService.showMessage("Saved search note refreshed."); } async batchUpdate(cb) { try { // disable rendering during update for increased performance this.tree.enableUpdate(false); await cb(); } finally { this.tree.enableUpdate(true); } } activityDetected() { if (this.autoCollapseTimeoutId) { clearTimeout(this.autoCollapseTimeoutId); } this.autoCollapseTimeoutId = setTimeout(() => { if (!this.autoCollapseNoteTree) { return; } /* * We're collapsing notes after period of inactivity to "cleanup" the tree - users rarely * collapse the notes and the tree becomes unusuably large. * Some context: https://github.com/zadam/trilium/issues/1192 */ const noteIdsToKeepExpanded = new Set( appContext.tabManager.getTabContexts() .map(tc => tc.notePathArray) .flat() ); let noneCollapsedYet = true; this.tree.getRootNode().visit(node => { if (node.isExpanded() && !noteIdsToKeepExpanded.has(node.data.noteId)) { node.setExpanded(false); if (noneCollapsedYet) { toastService.showMessage("Auto collapsing notes after inactivity..."); noneCollapsedYet = false; } } }, false); }, 600 * 1000); } async entitiesReloadedEvent({loadResults}) { this.activityDetected(); if (loadResults.isEmptyForTree()) { return; } const activeNode = this.getActiveNode(); const activeNodeFocused = activeNode && activeNode.hasFocus(); const nextNode = activeNode ? (activeNode.getNextSibling() || activeNode.getPrevSibling() || activeNode.getParent()) : null; const activeNotePath = activeNode ? treeService.getNotePath(activeNode) : null; const nextNotePath = nextNode ? treeService.getNotePath(nextNode) : null; const activeNoteId = activeNode ? activeNode.data.noteId : null; const noteIdsToUpdate = new Set(); const noteIdsToReload = new Set(); for (const attr of loadResults.getAttributes()) { if (attr.type === 'label' && ['iconClass', 'cssClass', 'workspace', 'workspaceIconClass', 'archived'].includes(attr.name)) { if (attr.isInheritable) { noteIdsToReload.add(attr.noteId); } else { noteIdsToUpdate.add(attr.noteId); } } else if (attr.type === 'relation' && attr.name === 'template') { // missing handling of things inherited from template noteIdsToReload.add(attr.noteId); } else if (attr.type === 'relation' && attr.name === 'imageLink') { const note = froca.getNoteFromCache(attr.noteId); if (note && note.getChildNoteIds().includes(attr.value)) { // there's new/deleted imageLink betwen note and its image child - which can show/hide // the image (if there is a imageLink relation between parent and child then it is assumed to be "contained" in the note and thus does not have to be displayed in the tree) noteIdsToReload.add(attr.noteId); } } } for (const branch of loadResults.getBranches()) { // adding noteId itself to update all potential clones noteIdsToUpdate.add(branch.noteId); for (const node of this.getNodesByBranchId(branch.branchId)) { if (branch.isDeleted) { if (node.isActive()) { const newActiveNode = node.getNextSibling() || node.getPrevSibling() || node.getParent(); if (newActiveNode) { newActiveNode.setActive(true, {noEvents: true, noFocus: true}); } } if (node.getParent()) { node.remove(); } noteIdsToUpdate.add(branch.parentNoteId); } } if (!branch.isDeleted) { for (const parentNode of this.getNodesByNoteId(branch.parentNoteId)) { if (parentNode.isFolder() && !parentNode.isLoaded()) { continue; } const found = (parentNode.getChildren() || []).find(child => child.data.noteId === branch.noteId); if (!found) { // make sure it's loaded await froca.getNote(branch.noteId); // we're forcing lazy since it's not clear if the whole required subtree is in froca parentNode.addChildren([this.prepareNode(branch, true)]); this.sortChildren(parentNode); // this might be a first child which would force an icon change noteIdsToUpdate.add(branch.parentNoteId); } } } } for (const noteId of loadResults.getNoteIds()) { noteIdsToUpdate.add(noteId); } await this.batchUpdate(async () => { for (const noteId of noteIdsToReload) { for (const node of this.getNodesByNoteId(noteId)) { await node.load(true); noteIdsToUpdate.add(noteId); } } for (const parentNoteId of loadResults.getNoteReorderings()) { for (const node of this.getNodesByNoteId(parentNoteId)) { if (node.isLoaded()) { this.sortChildren(node); } } } }); // for some reason node update cannot be in the batchUpdate() block (node is not re-rendered) for (const noteId of noteIdsToUpdate) { for (const node of this.getNodesByNoteId(noteId)) { this.updateNode(node); } } if (activeNotePath) { let node = await this.expandToNote(activeNotePath, false); if (node && node.data.noteId !== activeNoteId) { // if the active note has been moved elsewhere then it won't be found by the path // so we switch to the alternative of trying to find it by noteId const notesById = this.getNodesByNoteId(activeNoteId); // if there are multiple clones then we'd rather not activate any one node = notesById.length === 1 ? notesById[0] : null; } if (node) { if (activeNodeFocused) { // needed by Firefox: https://github.com/zadam/trilium/issues/1865 this.tree.$container.focus(); } await node.setActive(true, {noEvents: true, noFocus: !activeNodeFocused}); } else { // this is used when original note has been deleted and we want to move the focus to the note above/below node = await this.expandToNote(nextNotePath, false); if (node) { // FIXME: this is conceptually wrong // here note tree is responsible for updating global state of the application // this should be done by tabcontext / tabmanager and note tree should only listen to // changes in active note and just set the "active" state // We don't await since that can bring up infinite cycles when e.g. custom widget does some backend requests which wait for max sync ID processed appContext.tabManager.getActiveTabContext().setNote(nextNotePath).then(() => { const newActiveNode = this.getActiveNode(); // return focus if the previously active node was also focused if (newActiveNode && activeNodeFocused) {console.log("FOCUSING!!!"); newActiveNode.setFocus(true); } }); } } } if (noteIdsToReload.size > 0 || noteIdsToUpdate.size > 0) { // workaround for https://github.com/mar10/fancytree/issues/1054 this.filterHoistedBranch(); } } sortChildren(node) { node.sortChildren((nodeA, nodeB) => { const branchA = froca.branches[nodeA.data.branchId]; const branchB = froca.branches[nodeB.data.branchId]; if (!branchA || !branchB) { return 0; } return branchA.notePosition - branchB.notePosition; }); } setExpanded(branchId, isExpanded) { utils.assertArguments(branchId); const branch = froca.getBranch(branchId, true); if (!branch) { if (branchId && branchId.startsWith('virt')) { // in case of virtual branches there's nothing to update return; } else { logError(`Cannot find branch=${branchId}`); return; } } branch.isExpanded = isExpanded; server.put(`branches/${branchId}/expanded/${isExpanded ? 1 : 0}`); } async reloadTreeFromCache() { const activeNode = this.getActiveNode(); const activeNotePath = activeNode !== null ? treeService.getNotePath(activeNode) : null; const rootNode = this.prepareRootNode(); await this.batchUpdate(async () => { await this.tree.reload([rootNode]); }); if (activeNotePath) { const node = await this.getNodeFromPath(activeNotePath, true); await node.setActive(true, {noEvents: true, noFocus: true}); } } async hoistedNoteChangedEvent({tabId}) { if (this.isTab(tabId)) { this.filterHoistedBranch(); } } async filterHoistedBranch() { if (this.tabContext) { // make sure the hoisted node is loaded (can be unloaded e.g. after tree collapse in another tab) const hoistedNotePath = await treeService.resolveNotePath(this.tabContext.hoistedNoteId); await this.getNodeFromPath(hoistedNotePath); if (this.tabContext.hoistedNoteId === 'root') { this.tree.clearFilter(); } else { // hack when hoisted note is cloned then it could be filtered multiple times while we want only 1 this.tree.filterBranches(node => node.data.noteId === this.tabContext.hoistedNoteId // optimization to not having always resolve the node path && treeService.getNotePath(node) === hoistedNotePath); } } } frocaReloadedEvent() { this.reloadTreeFromCache(); } async getHotKeys() { const actions = await keyboardActionsService.getActionsForScope('note-tree'); const hotKeyMap = {}; for (const action of actions) { for (const shortcut of action.effectiveShortcuts) { hotKeyMap[utils.normalizeShortcut(shortcut)] = node => { const notePath = treeService.getNotePath(node); this.triggerCommand(action.actionName, {node, notePath}); return false; } } } return hotKeyMap; } /** * @param {FancytreeNode} node */ getSelectedOrActiveBranchIds(node) { const nodes = this.getSelectedOrActiveNodes(node); return nodes.map(node => node.data.branchId); } async deleteNotesCommand({node}) { const branchIds = this.getSelectedOrActiveBranchIds(node) .filter(branchId => !branchId.startsWith('virt-')); // search results can't be deleted if (!branchIds.length) { return; } await branchService.deleteNotes(branchIds); this.clearSelectedNodes(); } canBeMovedUpOrDown(node) { if (node.data.noteId === 'root') { return false; } const parentNote = froca.getNoteFromCache(node.getParent().data.noteId); if (parentNote && parentNote.hasLabel('sorted')) { return false; } return true; } moveNoteUpCommand({node}) { if (!this.canBeMovedUpOrDown(node)) { return; } const beforeNode = node.getPrevSibling(); if (beforeNode !== null) { branchService.moveBeforeBranch([node.data.branchId], beforeNode.data.branchId); } } moveNoteDownCommand({node}) { if (!this.canBeMovedUpOrDown(node)) { return; } const afterNode = node.getNextSibling(); if (afterNode !== null) { branchService.moveAfterBranch([node.data.branchId], afterNode.data.branchId); } } moveNoteUpInHierarchyCommand({node}) { branchService.moveNodeUpInHierarchy(node); } moveNoteDownInHierarchyCommand({node}) { const toNode = node.getPrevSibling(); if (toNode !== null) { branchService.moveToParentNote([node.data.branchId], toNode.data.branchId); } } addNoteAboveToSelectionCommand() { const node = this.getFocusedNode(); if (!node) { return; } if (node.isActive()) { node.setSelected(true); } const prevSibling = node.getPrevSibling(); if (prevSibling) { prevSibling.setActive(true, {noEvents: true}); if (prevSibling.isSelected()) { node.setSelected(false); } prevSibling.setSelected(true); } } addNoteBelowToSelectionCommand() { const node = this.getFocusedNode(); if (!node) { return; } if (node.isActive()) { node.setSelected(true); } const nextSibling = node.getNextSibling(); if (nextSibling) { nextSibling.setActive(true, {noEvents: true}); if (nextSibling.isSelected()) { node.setSelected(false); } nextSibling.setSelected(true); } } expandSubtreeCommand({node}) { this.expandTree(node); } collapseSubtreeCommand({node}) { this.collapseTree(node); } sortChildNotesCommand({node}) { import("../dialogs/sort_child_notes.js").then(d => d.showDialog(node.data.noteId)); } async recentChangesInSubtreeCommand({node}) { const recentChangesDialog = await import('../dialogs/recent_changes.js'); recentChangesDialog.showDialog(node.data.noteId); } selectAllNotesInParentCommand({node}) { for (const child of node.getParent().getChildren()) { child.setSelected(true); } } copyNotesToClipboardCommand({node}) { clipboard.copy(this.getSelectedOrActiveBranchIds(node)); } cutNotesToClipboardCommand({node}) { clipboard.cut(this.getSelectedOrActiveBranchIds(node)); } pasteNotesFromClipboardCommand({node}) { clipboard.pasteInto(node.data.branchId); } pasteNotesAfterFromClipboard({node}) { clipboard.pasteAfter(node.data.branchId); } async exportNoteCommand({node}) { const exportDialog = await import('../dialogs/export.js'); const notePath = treeService.getNotePath(node); exportDialog.showDialog(notePath,"subtree"); } async importIntoNoteCommand({node}) { const importDialog = await import('../dialogs/import.js'); importDialog.showDialog(node.data.noteId); } forceNoteSyncCommand({node}) { syncService.forceNoteSync(node.data.noteId); } editNoteTitleCommand({node}) { appContext.triggerCommand('focusOnTitle'); } protectSubtreeCommand({node}) { protectedSessionService.protectNote(node.data.noteId, true, true); } unprotectSubtreeCommand({node}) { protectedSessionService.protectNote(node.data.noteId, false, true); } duplicateSubtreeCommand({node}) { const nodesToDuplicate = this.getSelectedOrActiveNodes(node); for (const nodeToDuplicate of nodesToDuplicate) { const note = froca.getNoteFromCache(nodeToDuplicate.data.noteId); if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) { continue; } const branch = froca.getBranch(nodeToDuplicate.data.branchId); noteCreateService.duplicateSubtree(nodeToDuplicate.data.noteId, branch.parentNoteId); } } }