From 587ea42700c5c2abe81fe0b82cb9f5e7a1849449 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 09:27:40 +0200 Subject: [PATCH 01/31] chore(breadcrumb): get a menu to render on note link --- apps/client/src/widgets/layout/Breadcrumb.tsx | 18 +++++++++++++++++- apps/client/src/widgets/react/NoteLink.tsx | 11 ++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index a814fb13a..7358fa93e 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -6,6 +6,7 @@ import { Fragment } from "preact/jsx-runtime"; import appContext from "../../components/app_context"; import NoteContext from "../../components/note_context"; import FNote from "../../entities/fnote"; +import contextMenu from "../../menus/context_menu"; import link_context_menu from "../../menus/link_context_menu"; import { getReadableTextColor } from "../../services/css_class_manager"; import froca from "../../services/froca"; @@ -150,7 +151,22 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde ; } - return ; + return { + e.preventDefault(); + contextMenu.show({ + items: [ + { + title: "Foo" + } + ], + x: e.pageX, + y: e.pageY + }); + }} + />; } function BreadcrumbSeparator({ notePath, noteContext, activeNotePath }: { notePath: string, activeNotePath: string, noteContext: NoteContext | undefined }) { diff --git a/apps/client/src/widgets/react/NoteLink.tsx b/apps/client/src/widgets/react/NoteLink.tsx index 7c3578db7..3f7fbbdc0 100644 --- a/apps/client/src/widgets/react/NoteLink.tsx +++ b/apps/client/src/widgets/react/NoteLink.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "preact/hooks"; + import link, { ViewScope } from "../../services/link"; import { useImperativeSearchHighlighlighting, useTriliumEvent } from "./hooks"; @@ -16,9 +17,10 @@ interface NoteLinkOpts { title?: string; viewScope?: ViewScope; noContextMenu?: boolean; + onContextMenu?: (e: MouseEvent) => void; } -export default function NoteLink({ className, containerClassName, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens, title, viewScope, noContextMenu }: NoteLinkOpts) { +export default function NoteLink({ className, containerClassName, notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink, highlightedTokens, title, viewScope, noContextMenu, onContextMenu }: NoteLinkOpts) { const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath; const noteId = stringifiedNotePath.split("/").at(-1); const ref = useRef(null); @@ -35,6 +37,13 @@ export default function NoteLink({ className, containerClassName, notePath, show }).then(setJqueryEl); }, [ stringifiedNotePath, showNotePath, title, viewScope, noteTitle ]); + useEffect(() => { + const el = jqueryEl?.[0]; + if (!el || !onContextMenu) return; + el.addEventListener("contextmenu", onContextMenu); + return () => el.removeEventListener("contextmenu", onContextMenu); + }, [ jqueryEl, onContextMenu ]); + useEffect(() => { if (!ref.current || !jqueryEl) return; ref.current.replaceChildren(jqueryEl[0]); From 96a6ea4c7afc100a7b421aca61ddd5161dbbbbc3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 09:35:19 +0200 Subject: [PATCH 02/31] feat(breadcrumb): get tree menu to show --- apps/client/src/menus/tree_context_menu.ts | 491 +++++++++--------- apps/client/src/widgets/layout/Breadcrumb.tsx | 30 +- 2 files changed, 276 insertions(+), 245 deletions(-) diff --git a/apps/client/src/menus/tree_context_menu.ts b/apps/client/src/menus/tree_context_menu.ts index 51f9912b3..0f632827f 100644 --- a/apps/client/src/menus/tree_context_menu.ts +++ b/apps/client/src/menus/tree_context_menu.ts @@ -1,21 +1,23 @@ -import NoteColorPicker from "./custom-items/NoteColorPicker.jsx"; -import treeService from "../services/tree.js"; -import froca from "../services/froca.js"; -import clipboard from "../services/clipboard.js"; -import noteCreateService from "../services/note_create.js"; -import contextMenu, { type MenuCommandItem, type MenuItem } from "./context_menu.js"; import appContext, { type ContextMenuCommandData, type FilteredCommandNames } from "../components/app_context.js"; +import type { SelectMenuItemEventListener } from "../components/events.js"; +import type FAttachment from "../entities/fattachment.js"; +import FBranch from "../entities/fbranch.js"; +import FNote from "../entities/fnote.js"; +import attributes from "../services/attributes.js"; +import { executeBulkActions } from "../services/bulk_action.js"; +import clipboard from "../services/clipboard.js"; +import dialogService from "../services/dialog.js"; +import froca from "../services/froca.js"; +import { t } from "../services/i18n.js"; +import noteCreateService from "../services/note_create.js"; import noteTypesService from "../services/note_types.js"; import server from "../services/server.js"; import toastService from "../services/toast.js"; -import dialogService from "../services/dialog.js"; -import { t } from "../services/i18n.js"; -import type NoteTreeWidget from "../widgets/note_tree.js"; -import type FAttachment from "../entities/fattachment.js"; -import type { SelectMenuItemEventListener } from "../components/events.js"; +import treeService from "../services/tree.js"; import utils from "../services/utils.js"; -import attributes from "../services/attributes.js"; -import { executeBulkActions } from "../services/bulk_action.js"; +import type NoteTreeWidget from "../widgets/note_tree.js"; +import contextMenu, { type MenuCommandItem, type MenuItem } from "./context_menu.js"; +import NoteColorPicker from "./custom-items/NoteColorPicker.jsx"; // TODO: Deduplicate once client/server is well split. interface ConvertToAttachmentResponse { @@ -53,227 +55,16 @@ export default class TreeContextMenu implements SelectMenuItemEventListener[]> { + async getMenuItems() { const note = this.node.data.noteId ? await froca.getNote(this.node.data.noteId) : null; - const branch = froca.getBranch(this.node.data.branchId); - const isNotRoot = note?.noteId !== "root"; - const isHoisted = note?.noteId === appContext.tabManager.getActiveContext()?.hoistedNoteId; - const parentNote = isNotRoot && branch ? await froca.getNote(branch.parentNoteId) : null; - - // some actions don't support multi-note, so they are disabled when notes are selected, - // the only exception is when the only selected note is the one that was right-clicked, then - // it's clear what the user meant to do. const selNodes = this.treeWidget.getSelectedNodes(); - const selectedNotes = await froca.getNotes(selNodes.map(node => node.data.noteId)); - if (note && !selectedNotes.includes(note)) selectedNotes.push(note); - const isArchived = selectedNotes.every(note => note.isArchived); - const canToggleArchived = !selectedNotes.some(note => note.isArchived !== isArchived); - const noSelectedNotes = selNodes.length === 0 || (selNodes.length === 1 && selNodes[0] === this.node); - - const notSearch = note?.type !== "search"; - const notOptionsOrHelp = !note?.noteId.startsWith("_options") && !note?.noteId.startsWith("_help"); - const parentNotSearch = !parentNote || parentNote.type !== "search"; - const insertNoteAfterEnabled = isNotRoot && !isHoisted && parentNotSearch; - - const items: (MenuItem | null)[] = [ - { title: t("tree-context-menu.open-in-a-new-tab"), command: "openInTab", shortcut: "Ctrl+Click", uiIcon: "bx bx-link-external", enabled: noSelectedNotes }, - { title: t("tree-context-menu.open-in-a-new-split"), command: "openNoteInSplit", uiIcon: "bx bx-dock-right", enabled: noSelectedNotes }, - { title: t("tree-context-menu.open-in-popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit", enabled: noSelectedNotes }, - - isHoisted - ? null - : { - title: `${t("tree-context-menu.hoist-note")}`, - command: "toggleNoteHoisting", - keyboardShortcut: "toggleNoteHoisting", - uiIcon: "bx bxs-chevrons-up", - enabled: noSelectedNotes && notSearch - }, - !isHoisted || !isNotRoot - ? null - : { title: t("tree-context-menu.unhoist-note"), command: "toggleNoteHoisting", keyboardShortcut: "toggleNoteHoisting", uiIcon: "bx bx-door-open" }, - - { kind: "separator" }, - - { - title: t("tree-context-menu.insert-note-after"), - command: "insertNoteAfter", - keyboardShortcut: "createNoteAfter", - uiIcon: "bx bx-plus", - items: insertNoteAfterEnabled ? await noteTypesService.getNoteTypeItems("insertNoteAfter") : null, - enabled: insertNoteAfterEnabled && noSelectedNotes && notOptionsOrHelp, - columns: 2 - }, - - { - title: t("tree-context-menu.insert-child-note"), - command: "insertChildNote", - keyboardShortcut: "createNoteInto", - uiIcon: "bx bx-plus", - items: notSearch ? await noteTypesService.getNoteTypeItems("insertChildNote") : null, - enabled: notSearch && noSelectedNotes && notOptionsOrHelp, - columns: 2 - }, - - { kind: "separator" }, - - { title: t("tree-context-menu.protect-subtree"), command: "protectSubtree", uiIcon: "bx bx-check-shield", enabled: noSelectedNotes }, - - { title: t("tree-context-menu.unprotect-subtree"), command: "unprotectSubtree", uiIcon: "bx bx-shield", enabled: noSelectedNotes }, - - { kind: "separator" }, - - { - title: t("tree-context-menu.advanced"), - uiIcon: "bx bxs-wrench", - enabled: true, - items: [ - { title: t("tree-context-menu.apply-bulk-actions"), command: "openBulkActionsDialog", uiIcon: "bx bx-list-plus", enabled: true }, - - { kind: "separator" }, - - { - title: t("tree-context-menu.edit-branch-prefix"), - command: "editBranchPrefix", - keyboardShortcut: "editBranchPrefix", - uiIcon: "bx bx-rename", - enabled: isNotRoot && parentNotSearch && notOptionsOrHelp - }, - { - title: - t("tree-context-menu.convert-to-attachment"), - command: "convertNoteToAttachment", - uiIcon: "bx bx-paperclip", - enabled: isNotRoot && !isHoisted && notOptionsOrHelp && selectedNotes.some(note => note.isEligibleForConversionToAttachment()) - }, - - { kind: "separator" }, - - { title: t("tree-context-menu.expand-subtree"), command: "expandSubtree", keyboardShortcut: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes }, - { title: t("tree-context-menu.collapse-subtree"), command: "collapseSubtree", keyboardShortcut: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes }, - { - title: t("tree-context-menu.sort-by"), - command: "sortChildNotes", - keyboardShortcut: "sortChildNotes", - uiIcon: "bx bx-sort-down", - enabled: noSelectedNotes && notSearch - }, - - { kind: "separator" }, - - { title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true }, - { title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes && notOptionsOrHelp } - ] - }, - - { kind: "separator" }, - - { - title: t("tree-context-menu.cut"), - command: "cutNotesToClipboard", - keyboardShortcut: "cutNotesToClipboard", - uiIcon: "bx bx-cut", - enabled: isNotRoot && !isHoisted && parentNotSearch - }, - - { title: t("tree-context-menu.copy-clone"), command: "copyNotesToClipboard", keyboardShortcut: "copyNotesToClipboard", uiIcon: "bx bx-copy", enabled: isNotRoot && !isHoisted }, - - { - title: t("tree-context-menu.paste-into"), - command: "pasteNotesFromClipboard", - keyboardShortcut: "pasteNotesFromClipboard", - uiIcon: "bx bx-paste", - enabled: !clipboard.isClipboardEmpty() && notSearch && noSelectedNotes - }, - - { - title: t("tree-context-menu.paste-after"), - command: "pasteNotesAfterFromClipboard", - uiIcon: "bx bx-paste", - enabled: !clipboard.isClipboardEmpty() && isNotRoot && !isHoisted && parentNotSearch && noSelectedNotes - }, - - { - title: t("tree-context-menu.move-to"), - command: "moveNotesTo", - keyboardShortcut: "moveNotesTo", - uiIcon: "bx bx-transfer", - enabled: isNotRoot && !isHoisted && parentNotSearch - }, - - { title: t("tree-context-menu.clone-to"), command: "cloneNotesTo", keyboardShortcut: "cloneNotesTo", uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted }, - - { - title: t("tree-context-menu.duplicate"), - command: "duplicateSubtree", - keyboardShortcut: "duplicateSubtree", - uiIcon: "bx bx-outline", - enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp - }, - - { - title: !isArchived ? t("tree-context-menu.archive") : t("tree-context-menu.unarchive"), - uiIcon: !isArchived ? "bx bx-archive" : "bx bx-archive-out", - enabled: canToggleArchived, - handler: () => { - if (!selectedNotes.length) return; - - if (selectedNotes.length == 1) { - const note = selectedNotes[0]; - if (!isArchived) { - attributes.addLabel(note.noteId, "archived"); - } else { - attributes.removeOwnedLabelByName(note, "archived"); - } - } else { - const noteIds = selectedNotes.map(note => note.noteId); - if (!isArchived) { - executeBulkActions(noteIds, [{ - name: "addLabel", labelName: "archived" - }]); - } else { - executeBulkActions(noteIds, [{ - name: "deleteLabel", labelName: "archived" - }]); - } - } - } - }, - { - title: t("tree-context-menu.delete"), - command: "deleteNotes", - keyboardShortcut: "deleteNotes", - uiIcon: "bx bx-trash destructive-action-icon", - enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp - }, - - { kind: "separator"}, - - (notOptionsOrHelp && selectedNotes.length === 1) ? { - kind: "custom", - componentFn: () => { - return NoteColorPicker({note}); - } - } : null, - - { kind: "separator" }, - - { title: t("tree-context-menu.import-into-note"), command: "importIntoNote", uiIcon: "bx bx-import", enabled: notSearch && noSelectedNotes && notOptionsOrHelp }, - - { title: t("tree-context-menu.export"), command: "exportNote", uiIcon: "bx bx-export", enabled: notSearch && noSelectedNotes && notOptionsOrHelp }, - - { kind: "separator" }, - - { - title: t("tree-context-menu.search-in-subtree"), - command: "searchInSubtree", - keyboardShortcut: "searchInSubtree", - uiIcon: "bx bx-search", - enabled: notSearch && noSelectedNotes - } - ]; - return items.filter((row) => row !== null) as MenuItem[]; + return buildTreeMenuItems({ + note, + branch: froca.getBranch(this.node.data.branchId), + selectedNotes: await froca.getNotes(selNodes.map(node => node.data.noteId)), + noSelectedNotes: selNodes.length === 0 || (selNodes.length === 1 && selNodes[0] === this.node) + }); } async selectMenuItemHandler({ command, type, templateNoteId }: MenuCommandItem) { @@ -292,17 +83,17 @@ export default class TreeContextMenu implements SelectMenuItemEventListener(command, { node: this.node, - notePath: notePath, + notePath, noteId: this.node.data.noteId, selectedOrActiveBranchIds: this.treeWidget.getSelectedOrActiveBranchIds(this.node), selectedOrActiveNoteIds: this.treeWidget.getSelectedOrActiveNoteIds(this.node) @@ -344,3 +135,225 @@ export default class TreeContextMenu implements SelectMenuItemEventListener[]> { + const isNotRoot = note?.noteId !== "root"; + const isHoisted = note?.noteId === appContext.tabManager.getActiveContext()?.hoistedNoteId; + const parentNote = isNotRoot && branch ? await froca.getNote(branch.parentNoteId) : null; + + // some actions don't support multi-note, so they are disabled when notes are selected, + // the only exception is when the only selected note is the one that was right-clicked, then + // it's clear what the user meant to do. + if (note && !selectedNotes.includes(note)) selectedNotes.push(note); + const isArchived = selectedNotes.every(note => note.isArchived); + const canToggleArchived = !selectedNotes.some(note => note.isArchived !== isArchived); + + const notSearch = note?.type !== "search"; + const notOptionsOrHelp = !note?.noteId.startsWith("_options") && !note?.noteId.startsWith("_help"); + const parentNotSearch = !parentNote || parentNote.type !== "search"; + const insertNoteAfterEnabled = isNotRoot && !isHoisted && parentNotSearch; + + const items: (MenuItem | null)[] = [ + { title: t("tree-context-menu.open-in-a-new-tab"), command: "openInTab", shortcut: "Ctrl+Click", uiIcon: "bx bx-link-external", enabled: noSelectedNotes }, + { title: t("tree-context-menu.open-in-a-new-split"), command: "openNoteInSplit", uiIcon: "bx bx-dock-right", enabled: noSelectedNotes }, + { title: t("tree-context-menu.open-in-popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit", enabled: noSelectedNotes }, + + isHoisted + ? null + : { + title: `${t("tree-context-menu.hoist-note")}`, + command: "toggleNoteHoisting", + keyboardShortcut: "toggleNoteHoisting", + uiIcon: "bx bxs-chevrons-up", + enabled: noSelectedNotes && notSearch + }, + !isHoisted || !isNotRoot + ? null + : { title: t("tree-context-menu.unhoist-note"), command: "toggleNoteHoisting", keyboardShortcut: "toggleNoteHoisting", uiIcon: "bx bx-door-open" }, + + { kind: "separator" }, + + { + title: t("tree-context-menu.insert-note-after"), + command: "insertNoteAfter", + keyboardShortcut: "createNoteAfter", + uiIcon: "bx bx-plus", + items: insertNoteAfterEnabled ? await noteTypesService.getNoteTypeItems("insertNoteAfter") : null, + enabled: insertNoteAfterEnabled && noSelectedNotes && notOptionsOrHelp, + columns: 2 + }, + + { + title: t("tree-context-menu.insert-child-note"), + command: "insertChildNote", + keyboardShortcut: "createNoteInto", + uiIcon: "bx bx-plus", + items: notSearch ? await noteTypesService.getNoteTypeItems("insertChildNote") : null, + enabled: notSearch && noSelectedNotes && notOptionsOrHelp, + columns: 2 + }, + + { kind: "separator" }, + + { title: t("tree-context-menu.protect-subtree"), command: "protectSubtree", uiIcon: "bx bx-check-shield", enabled: noSelectedNotes }, + + { title: t("tree-context-menu.unprotect-subtree"), command: "unprotectSubtree", uiIcon: "bx bx-shield", enabled: noSelectedNotes }, + + { kind: "separator" }, + + { + title: t("tree-context-menu.advanced"), + uiIcon: "bx bxs-wrench", + enabled: true, + items: [ + { title: t("tree-context-menu.apply-bulk-actions"), command: "openBulkActionsDialog", uiIcon: "bx bx-list-plus", enabled: true }, + + { kind: "separator" }, + + { + title: t("tree-context-menu.edit-branch-prefix"), + command: "editBranchPrefix", + keyboardShortcut: "editBranchPrefix", + uiIcon: "bx bx-rename", + enabled: isNotRoot && parentNotSearch && notOptionsOrHelp + }, + { + title: + t("tree-context-menu.convert-to-attachment"), + command: "convertNoteToAttachment", + uiIcon: "bx bx-paperclip", + enabled: isNotRoot && !isHoisted && notOptionsOrHelp && selectedNotes.some(note => note.isEligibleForConversionToAttachment()) + }, + + { kind: "separator" }, + + { title: t("tree-context-menu.expand-subtree"), command: "expandSubtree", keyboardShortcut: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes }, + { title: t("tree-context-menu.collapse-subtree"), command: "collapseSubtree", keyboardShortcut: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes }, + { + title: t("tree-context-menu.sort-by"), + command: "sortChildNotes", + keyboardShortcut: "sortChildNotes", + uiIcon: "bx bx-sort-down", + enabled: noSelectedNotes && notSearch + }, + + { kind: "separator" }, + + { title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true }, + { title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes && notOptionsOrHelp } + ] + }, + + { kind: "separator" }, + + { + title: t("tree-context-menu.cut"), + command: "cutNotesToClipboard", + keyboardShortcut: "cutNotesToClipboard", + uiIcon: "bx bx-cut", + enabled: isNotRoot && !isHoisted && parentNotSearch + }, + + { title: t("tree-context-menu.copy-clone"), command: "copyNotesToClipboard", keyboardShortcut: "copyNotesToClipboard", uiIcon: "bx bx-copy", enabled: isNotRoot && !isHoisted }, + + { + title: t("tree-context-menu.paste-into"), + command: "pasteNotesFromClipboard", + keyboardShortcut: "pasteNotesFromClipboard", + uiIcon: "bx bx-paste", + enabled: !clipboard.isClipboardEmpty() && notSearch && noSelectedNotes + }, + + { + title: t("tree-context-menu.paste-after"), + command: "pasteNotesAfterFromClipboard", + uiIcon: "bx bx-paste", + enabled: !clipboard.isClipboardEmpty() && isNotRoot && !isHoisted && parentNotSearch && noSelectedNotes + }, + + { + title: t("tree-context-menu.move-to"), + command: "moveNotesTo", + keyboardShortcut: "moveNotesTo", + uiIcon: "bx bx-transfer", + enabled: isNotRoot && !isHoisted && parentNotSearch + }, + + { title: t("tree-context-menu.clone-to"), command: "cloneNotesTo", keyboardShortcut: "cloneNotesTo", uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted }, + + { + title: t("tree-context-menu.duplicate"), + command: "duplicateSubtree", + keyboardShortcut: "duplicateSubtree", + uiIcon: "bx bx-outline", + enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp + }, + + { + title: !isArchived ? t("tree-context-menu.archive") : t("tree-context-menu.unarchive"), + uiIcon: !isArchived ? "bx bx-archive" : "bx bx-archive-out", + enabled: canToggleArchived, + handler: () => { + if (!selectedNotes.length) return; + + if (selectedNotes.length == 1) { + const note = selectedNotes[0]; + if (!isArchived) { + attributes.addLabel(note.noteId, "archived"); + } else { + attributes.removeOwnedLabelByName(note, "archived"); + } + } else { + const noteIds = selectedNotes.map(note => note.noteId); + if (!isArchived) { + executeBulkActions(noteIds, [{ + name: "addLabel", labelName: "archived" + }]); + } else { + executeBulkActions(noteIds, [{ + name: "deleteLabel", labelName: "archived" + }]); + } + } + } + }, + { + title: t("tree-context-menu.delete"), + command: "deleteNotes", + keyboardShortcut: "deleteNotes", + uiIcon: "bx bx-trash destructive-action-icon", + enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp + }, + + { kind: "separator"}, + + (notOptionsOrHelp && selectedNotes.length === 1) ? { + kind: "custom", + componentFn: () => { + return NoteColorPicker({note}); + } + } : null, + + { kind: "separator" }, + + { title: t("tree-context-menu.import-into-note"), command: "importIntoNote", uiIcon: "bx bx-import", enabled: notSearch && noSelectedNotes && notOptionsOrHelp }, + + { title: t("tree-context-menu.export"), command: "exportNote", uiIcon: "bx bx-export", enabled: notSearch && noSelectedNotes && notOptionsOrHelp }, + + { kind: "separator" }, + + { + title: t("tree-context-menu.search-in-subtree"), + command: "searchInSubtree", + keyboardShortcut: "searchInSubtree", + uiIcon: "bx bx-search", + enabled: notSearch && noSelectedNotes + } + ]; + return items.filter((row) => row !== null) as MenuItem[]; +} diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index 7358fa93e..b8bf36b37 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -8,6 +8,7 @@ import NoteContext from "../../components/note_context"; import FNote from "../../entities/fnote"; import contextMenu from "../../menus/context_menu"; import link_context_menu from "../../menus/link_context_menu"; +import { buildTreeMenuItems } from "../../menus/tree_context_menu"; import { getReadableTextColor } from "../../services/css_class_manager"; import froca from "../../services/froca"; import hoisted_note from "../../services/hoisted_note"; @@ -154,14 +155,31 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde return { + onContextMenu={async (e) => { e.preventDefault(); + + const notePathArray = notePath.split("/"); + const parentNoteId = notePathArray.at(-2); + const childNoteId = notePathArray.at(-1); + console.log(parentNoteId, childNoteId); + if (!parentNoteId || !childNoteId) return; + + const branchId = await froca.getBranchId(parentNoteId, childNoteId); + if (!branchId) return; + + const branch = froca.getBranch(branchId); + const note = await branch?.getNote(); + if (!branch || !note) return; + + const items = await buildTreeMenuItems({ + branch, + note, + noSelectedNotes: true, + selectedNotes: [] + }); + contextMenu.show({ - items: [ - { - title: "Foo" - } - ], + items, x: e.pageX, y: e.pageY }); From 9d581347f1e4e87ef4981ac8b2ea9abb088ed302 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 09:56:21 +0200 Subject: [PATCH 03/31] Revert "feat(breadcrumb): get tree menu to show" This reverts commit 96a6ea4c7afc100a7b421aca61ddd5161dbbbbc3. --- apps/client/src/menus/tree_context_menu.ts | 491 +++++++++--------- apps/client/src/widgets/layout/Breadcrumb.tsx | 30 +- 2 files changed, 245 insertions(+), 276 deletions(-) diff --git a/apps/client/src/menus/tree_context_menu.ts b/apps/client/src/menus/tree_context_menu.ts index 0f632827f..51f9912b3 100644 --- a/apps/client/src/menus/tree_context_menu.ts +++ b/apps/client/src/menus/tree_context_menu.ts @@ -1,23 +1,21 @@ -import appContext, { type ContextMenuCommandData, type FilteredCommandNames } from "../components/app_context.js"; -import type { SelectMenuItemEventListener } from "../components/events.js"; -import type FAttachment from "../entities/fattachment.js"; -import FBranch from "../entities/fbranch.js"; -import FNote from "../entities/fnote.js"; -import attributes from "../services/attributes.js"; -import { executeBulkActions } from "../services/bulk_action.js"; -import clipboard from "../services/clipboard.js"; -import dialogService from "../services/dialog.js"; +import NoteColorPicker from "./custom-items/NoteColorPicker.jsx"; +import treeService from "../services/tree.js"; import froca from "../services/froca.js"; -import { t } from "../services/i18n.js"; +import clipboard from "../services/clipboard.js"; import noteCreateService from "../services/note_create.js"; +import contextMenu, { type MenuCommandItem, type MenuItem } from "./context_menu.js"; +import appContext, { type ContextMenuCommandData, type FilteredCommandNames } from "../components/app_context.js"; import noteTypesService from "../services/note_types.js"; import server from "../services/server.js"; import toastService from "../services/toast.js"; -import treeService from "../services/tree.js"; -import utils from "../services/utils.js"; +import dialogService from "../services/dialog.js"; +import { t } from "../services/i18n.js"; import type NoteTreeWidget from "../widgets/note_tree.js"; -import contextMenu, { type MenuCommandItem, type MenuItem } from "./context_menu.js"; -import NoteColorPicker from "./custom-items/NoteColorPicker.jsx"; +import type FAttachment from "../entities/fattachment.js"; +import type { SelectMenuItemEventListener } from "../components/events.js"; +import utils from "../services/utils.js"; +import attributes from "../services/attributes.js"; +import { executeBulkActions } from "../services/bulk_action.js"; // TODO: Deduplicate once client/server is well split. interface ConvertToAttachmentResponse { @@ -55,16 +53,227 @@ export default class TreeContextMenu implements SelectMenuItemEventListener[]> { const note = this.node.data.noteId ? await froca.getNote(this.node.data.noteId) : null; - const selNodes = this.treeWidget.getSelectedNodes(); + const branch = froca.getBranch(this.node.data.branchId); + const isNotRoot = note?.noteId !== "root"; + const isHoisted = note?.noteId === appContext.tabManager.getActiveContext()?.hoistedNoteId; + const parentNote = isNotRoot && branch ? await froca.getNote(branch.parentNoteId) : null; - return buildTreeMenuItems({ - note, - branch: froca.getBranch(this.node.data.branchId), - selectedNotes: await froca.getNotes(selNodes.map(node => node.data.noteId)), - noSelectedNotes: selNodes.length === 0 || (selNodes.length === 1 && selNodes[0] === this.node) - }); + // some actions don't support multi-note, so they are disabled when notes are selected, + // the only exception is when the only selected note is the one that was right-clicked, then + // it's clear what the user meant to do. + const selNodes = this.treeWidget.getSelectedNodes(); + const selectedNotes = await froca.getNotes(selNodes.map(node => node.data.noteId)); + if (note && !selectedNotes.includes(note)) selectedNotes.push(note); + const isArchived = selectedNotes.every(note => note.isArchived); + const canToggleArchived = !selectedNotes.some(note => note.isArchived !== isArchived); + + const noSelectedNotes = selNodes.length === 0 || (selNodes.length === 1 && selNodes[0] === this.node); + + const notSearch = note?.type !== "search"; + const notOptionsOrHelp = !note?.noteId.startsWith("_options") && !note?.noteId.startsWith("_help"); + const parentNotSearch = !parentNote || parentNote.type !== "search"; + const insertNoteAfterEnabled = isNotRoot && !isHoisted && parentNotSearch; + + const items: (MenuItem | null)[] = [ + { title: t("tree-context-menu.open-in-a-new-tab"), command: "openInTab", shortcut: "Ctrl+Click", uiIcon: "bx bx-link-external", enabled: noSelectedNotes }, + { title: t("tree-context-menu.open-in-a-new-split"), command: "openNoteInSplit", uiIcon: "bx bx-dock-right", enabled: noSelectedNotes }, + { title: t("tree-context-menu.open-in-popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit", enabled: noSelectedNotes }, + + isHoisted + ? null + : { + title: `${t("tree-context-menu.hoist-note")}`, + command: "toggleNoteHoisting", + keyboardShortcut: "toggleNoteHoisting", + uiIcon: "bx bxs-chevrons-up", + enabled: noSelectedNotes && notSearch + }, + !isHoisted || !isNotRoot + ? null + : { title: t("tree-context-menu.unhoist-note"), command: "toggleNoteHoisting", keyboardShortcut: "toggleNoteHoisting", uiIcon: "bx bx-door-open" }, + + { kind: "separator" }, + + { + title: t("tree-context-menu.insert-note-after"), + command: "insertNoteAfter", + keyboardShortcut: "createNoteAfter", + uiIcon: "bx bx-plus", + items: insertNoteAfterEnabled ? await noteTypesService.getNoteTypeItems("insertNoteAfter") : null, + enabled: insertNoteAfterEnabled && noSelectedNotes && notOptionsOrHelp, + columns: 2 + }, + + { + title: t("tree-context-menu.insert-child-note"), + command: "insertChildNote", + keyboardShortcut: "createNoteInto", + uiIcon: "bx bx-plus", + items: notSearch ? await noteTypesService.getNoteTypeItems("insertChildNote") : null, + enabled: notSearch && noSelectedNotes && notOptionsOrHelp, + columns: 2 + }, + + { kind: "separator" }, + + { title: t("tree-context-menu.protect-subtree"), command: "protectSubtree", uiIcon: "bx bx-check-shield", enabled: noSelectedNotes }, + + { title: t("tree-context-menu.unprotect-subtree"), command: "unprotectSubtree", uiIcon: "bx bx-shield", enabled: noSelectedNotes }, + + { kind: "separator" }, + + { + title: t("tree-context-menu.advanced"), + uiIcon: "bx bxs-wrench", + enabled: true, + items: [ + { title: t("tree-context-menu.apply-bulk-actions"), command: "openBulkActionsDialog", uiIcon: "bx bx-list-plus", enabled: true }, + + { kind: "separator" }, + + { + title: t("tree-context-menu.edit-branch-prefix"), + command: "editBranchPrefix", + keyboardShortcut: "editBranchPrefix", + uiIcon: "bx bx-rename", + enabled: isNotRoot && parentNotSearch && notOptionsOrHelp + }, + { + title: + t("tree-context-menu.convert-to-attachment"), + command: "convertNoteToAttachment", + uiIcon: "bx bx-paperclip", + enabled: isNotRoot && !isHoisted && notOptionsOrHelp && selectedNotes.some(note => note.isEligibleForConversionToAttachment()) + }, + + { kind: "separator" }, + + { title: t("tree-context-menu.expand-subtree"), command: "expandSubtree", keyboardShortcut: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes }, + { title: t("tree-context-menu.collapse-subtree"), command: "collapseSubtree", keyboardShortcut: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes }, + { + title: t("tree-context-menu.sort-by"), + command: "sortChildNotes", + keyboardShortcut: "sortChildNotes", + uiIcon: "bx bx-sort-down", + enabled: noSelectedNotes && notSearch + }, + + { kind: "separator" }, + + { title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true }, + { title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes && notOptionsOrHelp } + ] + }, + + { kind: "separator" }, + + { + title: t("tree-context-menu.cut"), + command: "cutNotesToClipboard", + keyboardShortcut: "cutNotesToClipboard", + uiIcon: "bx bx-cut", + enabled: isNotRoot && !isHoisted && parentNotSearch + }, + + { title: t("tree-context-menu.copy-clone"), command: "copyNotesToClipboard", keyboardShortcut: "copyNotesToClipboard", uiIcon: "bx bx-copy", enabled: isNotRoot && !isHoisted }, + + { + title: t("tree-context-menu.paste-into"), + command: "pasteNotesFromClipboard", + keyboardShortcut: "pasteNotesFromClipboard", + uiIcon: "bx bx-paste", + enabled: !clipboard.isClipboardEmpty() && notSearch && noSelectedNotes + }, + + { + title: t("tree-context-menu.paste-after"), + command: "pasteNotesAfterFromClipboard", + uiIcon: "bx bx-paste", + enabled: !clipboard.isClipboardEmpty() && isNotRoot && !isHoisted && parentNotSearch && noSelectedNotes + }, + + { + title: t("tree-context-menu.move-to"), + command: "moveNotesTo", + keyboardShortcut: "moveNotesTo", + uiIcon: "bx bx-transfer", + enabled: isNotRoot && !isHoisted && parentNotSearch + }, + + { title: t("tree-context-menu.clone-to"), command: "cloneNotesTo", keyboardShortcut: "cloneNotesTo", uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted }, + + { + title: t("tree-context-menu.duplicate"), + command: "duplicateSubtree", + keyboardShortcut: "duplicateSubtree", + uiIcon: "bx bx-outline", + enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp + }, + + { + title: !isArchived ? t("tree-context-menu.archive") : t("tree-context-menu.unarchive"), + uiIcon: !isArchived ? "bx bx-archive" : "bx bx-archive-out", + enabled: canToggleArchived, + handler: () => { + if (!selectedNotes.length) return; + + if (selectedNotes.length == 1) { + const note = selectedNotes[0]; + if (!isArchived) { + attributes.addLabel(note.noteId, "archived"); + } else { + attributes.removeOwnedLabelByName(note, "archived"); + } + } else { + const noteIds = selectedNotes.map(note => note.noteId); + if (!isArchived) { + executeBulkActions(noteIds, [{ + name: "addLabel", labelName: "archived" + }]); + } else { + executeBulkActions(noteIds, [{ + name: "deleteLabel", labelName: "archived" + }]); + } + } + } + }, + { + title: t("tree-context-menu.delete"), + command: "deleteNotes", + keyboardShortcut: "deleteNotes", + uiIcon: "bx bx-trash destructive-action-icon", + enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp + }, + + { kind: "separator"}, + + (notOptionsOrHelp && selectedNotes.length === 1) ? { + kind: "custom", + componentFn: () => { + return NoteColorPicker({note}); + } + } : null, + + { kind: "separator" }, + + { title: t("tree-context-menu.import-into-note"), command: "importIntoNote", uiIcon: "bx bx-import", enabled: notSearch && noSelectedNotes && notOptionsOrHelp }, + + { title: t("tree-context-menu.export"), command: "exportNote", uiIcon: "bx bx-export", enabled: notSearch && noSelectedNotes && notOptionsOrHelp }, + + { kind: "separator" }, + + { + title: t("tree-context-menu.search-in-subtree"), + command: "searchInSubtree", + keyboardShortcut: "searchInSubtree", + uiIcon: "bx bx-search", + enabled: notSearch && noSelectedNotes + } + ]; + return items.filter((row) => row !== null) as MenuItem[]; } async selectMenuItemHandler({ command, type, templateNoteId }: MenuCommandItem) { @@ -83,17 +292,17 @@ export default class TreeContextMenu implements SelectMenuItemEventListener(command, { node: this.node, - notePath, + notePath: notePath, noteId: this.node.data.noteId, selectedOrActiveBranchIds: this.treeWidget.getSelectedOrActiveBranchIds(this.node), selectedOrActiveNoteIds: this.treeWidget.getSelectedOrActiveNoteIds(this.node) @@ -135,225 +344,3 @@ export default class TreeContextMenu implements SelectMenuItemEventListener[]> { - const isNotRoot = note?.noteId !== "root"; - const isHoisted = note?.noteId === appContext.tabManager.getActiveContext()?.hoistedNoteId; - const parentNote = isNotRoot && branch ? await froca.getNote(branch.parentNoteId) : null; - - // some actions don't support multi-note, so they are disabled when notes are selected, - // the only exception is when the only selected note is the one that was right-clicked, then - // it's clear what the user meant to do. - if (note && !selectedNotes.includes(note)) selectedNotes.push(note); - const isArchived = selectedNotes.every(note => note.isArchived); - const canToggleArchived = !selectedNotes.some(note => note.isArchived !== isArchived); - - const notSearch = note?.type !== "search"; - const notOptionsOrHelp = !note?.noteId.startsWith("_options") && !note?.noteId.startsWith("_help"); - const parentNotSearch = !parentNote || parentNote.type !== "search"; - const insertNoteAfterEnabled = isNotRoot && !isHoisted && parentNotSearch; - - const items: (MenuItem | null)[] = [ - { title: t("tree-context-menu.open-in-a-new-tab"), command: "openInTab", shortcut: "Ctrl+Click", uiIcon: "bx bx-link-external", enabled: noSelectedNotes }, - { title: t("tree-context-menu.open-in-a-new-split"), command: "openNoteInSplit", uiIcon: "bx bx-dock-right", enabled: noSelectedNotes }, - { title: t("tree-context-menu.open-in-popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit", enabled: noSelectedNotes }, - - isHoisted - ? null - : { - title: `${t("tree-context-menu.hoist-note")}`, - command: "toggleNoteHoisting", - keyboardShortcut: "toggleNoteHoisting", - uiIcon: "bx bxs-chevrons-up", - enabled: noSelectedNotes && notSearch - }, - !isHoisted || !isNotRoot - ? null - : { title: t("tree-context-menu.unhoist-note"), command: "toggleNoteHoisting", keyboardShortcut: "toggleNoteHoisting", uiIcon: "bx bx-door-open" }, - - { kind: "separator" }, - - { - title: t("tree-context-menu.insert-note-after"), - command: "insertNoteAfter", - keyboardShortcut: "createNoteAfter", - uiIcon: "bx bx-plus", - items: insertNoteAfterEnabled ? await noteTypesService.getNoteTypeItems("insertNoteAfter") : null, - enabled: insertNoteAfterEnabled && noSelectedNotes && notOptionsOrHelp, - columns: 2 - }, - - { - title: t("tree-context-menu.insert-child-note"), - command: "insertChildNote", - keyboardShortcut: "createNoteInto", - uiIcon: "bx bx-plus", - items: notSearch ? await noteTypesService.getNoteTypeItems("insertChildNote") : null, - enabled: notSearch && noSelectedNotes && notOptionsOrHelp, - columns: 2 - }, - - { kind: "separator" }, - - { title: t("tree-context-menu.protect-subtree"), command: "protectSubtree", uiIcon: "bx bx-check-shield", enabled: noSelectedNotes }, - - { title: t("tree-context-menu.unprotect-subtree"), command: "unprotectSubtree", uiIcon: "bx bx-shield", enabled: noSelectedNotes }, - - { kind: "separator" }, - - { - title: t("tree-context-menu.advanced"), - uiIcon: "bx bxs-wrench", - enabled: true, - items: [ - { title: t("tree-context-menu.apply-bulk-actions"), command: "openBulkActionsDialog", uiIcon: "bx bx-list-plus", enabled: true }, - - { kind: "separator" }, - - { - title: t("tree-context-menu.edit-branch-prefix"), - command: "editBranchPrefix", - keyboardShortcut: "editBranchPrefix", - uiIcon: "bx bx-rename", - enabled: isNotRoot && parentNotSearch && notOptionsOrHelp - }, - { - title: - t("tree-context-menu.convert-to-attachment"), - command: "convertNoteToAttachment", - uiIcon: "bx bx-paperclip", - enabled: isNotRoot && !isHoisted && notOptionsOrHelp && selectedNotes.some(note => note.isEligibleForConversionToAttachment()) - }, - - { kind: "separator" }, - - { title: t("tree-context-menu.expand-subtree"), command: "expandSubtree", keyboardShortcut: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes }, - { title: t("tree-context-menu.collapse-subtree"), command: "collapseSubtree", keyboardShortcut: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes }, - { - title: t("tree-context-menu.sort-by"), - command: "sortChildNotes", - keyboardShortcut: "sortChildNotes", - uiIcon: "bx bx-sort-down", - enabled: noSelectedNotes && notSearch - }, - - { kind: "separator" }, - - { title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true }, - { title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes && notOptionsOrHelp } - ] - }, - - { kind: "separator" }, - - { - title: t("tree-context-menu.cut"), - command: "cutNotesToClipboard", - keyboardShortcut: "cutNotesToClipboard", - uiIcon: "bx bx-cut", - enabled: isNotRoot && !isHoisted && parentNotSearch - }, - - { title: t("tree-context-menu.copy-clone"), command: "copyNotesToClipboard", keyboardShortcut: "copyNotesToClipboard", uiIcon: "bx bx-copy", enabled: isNotRoot && !isHoisted }, - - { - title: t("tree-context-menu.paste-into"), - command: "pasteNotesFromClipboard", - keyboardShortcut: "pasteNotesFromClipboard", - uiIcon: "bx bx-paste", - enabled: !clipboard.isClipboardEmpty() && notSearch && noSelectedNotes - }, - - { - title: t("tree-context-menu.paste-after"), - command: "pasteNotesAfterFromClipboard", - uiIcon: "bx bx-paste", - enabled: !clipboard.isClipboardEmpty() && isNotRoot && !isHoisted && parentNotSearch && noSelectedNotes - }, - - { - title: t("tree-context-menu.move-to"), - command: "moveNotesTo", - keyboardShortcut: "moveNotesTo", - uiIcon: "bx bx-transfer", - enabled: isNotRoot && !isHoisted && parentNotSearch - }, - - { title: t("tree-context-menu.clone-to"), command: "cloneNotesTo", keyboardShortcut: "cloneNotesTo", uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted }, - - { - title: t("tree-context-menu.duplicate"), - command: "duplicateSubtree", - keyboardShortcut: "duplicateSubtree", - uiIcon: "bx bx-outline", - enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp - }, - - { - title: !isArchived ? t("tree-context-menu.archive") : t("tree-context-menu.unarchive"), - uiIcon: !isArchived ? "bx bx-archive" : "bx bx-archive-out", - enabled: canToggleArchived, - handler: () => { - if (!selectedNotes.length) return; - - if (selectedNotes.length == 1) { - const note = selectedNotes[0]; - if (!isArchived) { - attributes.addLabel(note.noteId, "archived"); - } else { - attributes.removeOwnedLabelByName(note, "archived"); - } - } else { - const noteIds = selectedNotes.map(note => note.noteId); - if (!isArchived) { - executeBulkActions(noteIds, [{ - name: "addLabel", labelName: "archived" - }]); - } else { - executeBulkActions(noteIds, [{ - name: "deleteLabel", labelName: "archived" - }]); - } - } - } - }, - { - title: t("tree-context-menu.delete"), - command: "deleteNotes", - keyboardShortcut: "deleteNotes", - uiIcon: "bx bx-trash destructive-action-icon", - enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp - }, - - { kind: "separator"}, - - (notOptionsOrHelp && selectedNotes.length === 1) ? { - kind: "custom", - componentFn: () => { - return NoteColorPicker({note}); - } - } : null, - - { kind: "separator" }, - - { title: t("tree-context-menu.import-into-note"), command: "importIntoNote", uiIcon: "bx bx-import", enabled: notSearch && noSelectedNotes && notOptionsOrHelp }, - - { title: t("tree-context-menu.export"), command: "exportNote", uiIcon: "bx bx-export", enabled: notSearch && noSelectedNotes && notOptionsOrHelp }, - - { kind: "separator" }, - - { - title: t("tree-context-menu.search-in-subtree"), - command: "searchInSubtree", - keyboardShortcut: "searchInSubtree", - uiIcon: "bx bx-search", - enabled: notSearch && noSelectedNotes - } - ]; - return items.filter((row) => row !== null) as MenuItem[]; -} diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index b8bf36b37..7358fa93e 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -8,7 +8,6 @@ import NoteContext from "../../components/note_context"; import FNote from "../../entities/fnote"; import contextMenu from "../../menus/context_menu"; import link_context_menu from "../../menus/link_context_menu"; -import { buildTreeMenuItems } from "../../menus/tree_context_menu"; import { getReadableTextColor } from "../../services/css_class_manager"; import froca from "../../services/froca"; import hoisted_note from "../../services/hoisted_note"; @@ -155,31 +154,14 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde return { + onContextMenu={(e) => { e.preventDefault(); - - const notePathArray = notePath.split("/"); - const parentNoteId = notePathArray.at(-2); - const childNoteId = notePathArray.at(-1); - console.log(parentNoteId, childNoteId); - if (!parentNoteId || !childNoteId) return; - - const branchId = await froca.getBranchId(parentNoteId, childNoteId); - if (!branchId) return; - - const branch = froca.getBranch(branchId); - const note = await branch?.getNote(); - if (!branch || !note) return; - - const items = await buildTreeMenuItems({ - branch, - note, - noSelectedNotes: true, - selectedNotes: [] - }); - contextMenu.show({ - items, + items: [ + { + title: "Foo" + } + ], x: e.pageX, y: e.pageY }); From cb8e35c4dc822b463cba52893ccbfa61075f2bab Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 10:03:28 +0200 Subject: [PATCH 04/31] feat(breadcrumb): get link menu items --- apps/client/src/widgets/layout/Breadcrumb.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index 7358fa93e..498159514 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -154,16 +154,21 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde return { + onContextMenu={async (e) => { e.preventDefault(); + + const noteId = notePath.split("/").at(-1); + if (!noteId) return; + const note = await froca.getNote(noteId); + if (!note) return; + contextMenu.show({ items: [ - { - title: "Foo" - } + ...link_context_menu.getItems(e), ], x: e.pageX, - y: e.pageY + y: e.pageY, + selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, e, note.noteId), }); }} />; From 51fcda646dfb341dc162e4f0b3de039db76d18bb Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 10:09:10 +0200 Subject: [PATCH 05/31] feat(breadcrumb): add hoist option --- apps/client/src/menus/link_context_menu.ts | 23 ++++++++++------ apps/client/src/widgets/layout/Breadcrumb.tsx | 26 +++++++++++++++++-- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/apps/client/src/menus/link_context_menu.ts b/apps/client/src/menus/link_context_menu.ts index 0799a58ab..2cba3aab4 100644 --- a/apps/client/src/menus/link_context_menu.ts +++ b/apps/client/src/menus/link_context_menu.ts @@ -1,10 +1,11 @@ -import { t } from "../services/i18n.js"; -import contextMenu, { type ContextMenuEvent, type MenuItem } from "./context_menu.js"; +import type { LeafletMouseEvent } from "leaflet"; + import appContext, { type CommandNames } from "../components/app_context.js"; +import { t } from "../services/i18n.js"; import type { ViewScope } from "../services/link.js"; import utils, { isMobile } from "../services/utils.js"; import { getClosestNtxId } from "../widgets/widget_utils.js"; -import type { LeafletMouseEvent } from "leaflet"; +import contextMenu, { type ContextMenuEvent, type MenuItem } from "./context_menu.js"; function openContextMenu(notePath: string, e: ContextMenuEvent, viewScope: ViewScope = {}, hoistedNoteId: string | null = null) { contextMenu.show({ @@ -34,15 +35,21 @@ function handleLinkContextMenuItem(command: string | undefined, e: ContextMenuEv if (command === "openNoteInNewTab") { appContext.tabManager.openContextWithNote(notePath, { hoistedNoteId, viewScope }); + return true; } else if (command === "openNoteInNewSplit") { const ntxId = getNtxId(e); - if (!ntxId) return; + if (!ntxId) return false; appContext.triggerCommand("openNewNoteSplit", { ntxId, notePath, hoistedNoteId, viewScope }); + return true; } else if (command === "openNoteInNewWindow") { appContext.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope }); + return true; } else if (command === "openNoteInPopup") { - appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath }) + appContext.triggerCommand("openInPopup", { noteIdOrPath: notePath }); + return true; } + + return false; } function getNtxId(e: ContextMenuEvent | LeafletMouseEvent) { @@ -52,9 +59,9 @@ function getNtxId(e: ContextMenuEvent | LeafletMouseEvent) { return subContexts[subContexts.length - 1].ntxId; } else if (e.target instanceof HTMLElement) { return getClosestNtxId(e.target); - } else { - return null; - } + } + return null; + } export default { diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index 498159514..ed4e42c7c 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -1,6 +1,6 @@ import "./Breadcrumb.css"; -import { useRef, useState } from "preact/hooks"; +import { useContext, useRef, useState } from "preact/hooks"; import { Fragment } from "preact/jsx-runtime"; import appContext from "../../components/app_context"; @@ -19,6 +19,7 @@ import { FormListItem } from "../react/FormList"; import { useChildNotes, useNote, useNoteIcon, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useStaticTooltip } from "../react/hooks"; import Icon from "../react/Icon"; import NoteLink from "../react/NoteLink"; +import { ParentComponent } from "../react/react_utils"; const COLLAPSE_THRESHOLD = 5; const INITIAL_ITEMS = 2; @@ -141,6 +142,8 @@ function BreadcrumbLastItem({ notePath }: { notePath: string }) { } function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { index: number, notePathLength: number, notePath: string, noteContext: NoteContext | undefined }) { + const parentComponent = useContext(ParentComponent); + if (index === 0) { return ; } @@ -162,13 +165,32 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde const note = await froca.getNote(noteId); if (!note) return; + const notSearch = note?.type !== "search"; + contextMenu.show({ items: [ ...link_context_menu.getItems(e), + { + title: `${t("tree-context-menu.hoist-note")}`, + command: "toggleNoteHoisting", + keyboardShortcut: "toggleNoteHoisting", + uiIcon: "bx bxs-chevrons-up", + enabled: notSearch + }, ], x: e.pageX, y: e.pageY, - selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, e, note.noteId), + selectMenuItemHandler: ({ command }) => { + if (link_context_menu.handleLinkContextMenuItem(command, e, note.noteId)) { + return; + } + + if (command) { + parentComponent?.triggerCommand(command, { + noteId + }); + } + }, }); }} />; From e91cb1a198505b8cc7a3dcf30e92db8fd8d51d49 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 10:16:09 +0200 Subject: [PATCH 06/31] feat(breadcrumb): add clone to/move to options --- apps/client/src/widgets/layout/Breadcrumb.tsx | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index ed4e42c7c..4ec4f4534 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -160,12 +160,23 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde onContextMenu={async (e) => { e.preventDefault(); - const noteId = notePath.split("/").at(-1); - if (!noteId) return; + const notePathComponents = notePath.split("/"); + const parentNoteId = notePathComponents.at(-2); + const noteId = notePathComponents.at(-1); + if (!parentNoteId || !noteId) return; + + const branchId = await froca.getBranchId(parentNoteId, noteId); + if (!branchId) return; + const branch = froca.getBranch(branchId); + const note = await froca.getNote(noteId); if (!note) return; const notSearch = note?.type !== "search"; + const isNotRoot = note?.noteId !== "root"; + const isHoisted = note?.noteId === appContext.tabManager.getActiveContext()?.hoistedNoteId; + const parentNote = isNotRoot && branch ? await froca.getNote(branch.parentNoteId) : null; + const parentNotSearch = !parentNote || parentNote.type !== "search"; contextMenu.show({ items: [ @@ -177,6 +188,21 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde uiIcon: "bx bxs-chevrons-up", enabled: notSearch }, + { kind: "separator" }, + { + title: t("tree-context-menu.move-to"), + command: "moveNotesTo", + keyboardShortcut: "moveNotesTo", + uiIcon: "bx bx-transfer", + enabled: isNotRoot && !isHoisted && parentNotSearch + }, + { + title: t("tree-context-menu.clone-to"), + command: "cloneNotesTo", + keyboardShortcut: "cloneNotesTo", + uiIcon: "bx bx-duplicate", + enabled: isNotRoot && !isHoisted + }, ], x: e.pageX, y: e.pageY, @@ -187,7 +213,9 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde if (command) { parentComponent?.triggerCommand(command, { - noteId + noteId, + selectedOrActiveBranchIds: [ branchId ], + selectedOrActiveNoteIds: [ noteId ] }); } }, From 0b7ffdf1095aeab876ef09efcaf1f02a4cfde9d9 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 10:20:15 +0200 Subject: [PATCH 07/31] chore(breadcrumb): remove keyboard shortcuts --- apps/client/src/widgets/layout/Breadcrumb.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index 4ec4f4534..be09a1b78 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -184,7 +184,6 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde { title: `${t("tree-context-menu.hoist-note")}`, command: "toggleNoteHoisting", - keyboardShortcut: "toggleNoteHoisting", uiIcon: "bx bxs-chevrons-up", enabled: notSearch }, @@ -192,14 +191,12 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde { title: t("tree-context-menu.move-to"), command: "moveNotesTo", - keyboardShortcut: "moveNotesTo", uiIcon: "bx bx-transfer", enabled: isNotRoot && !isHoisted && parentNotSearch }, { title: t("tree-context-menu.clone-to"), command: "cloneNotesTo", - keyboardShortcut: "cloneNotesTo", uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted }, From d13e19cf594c26ffd98460ec16b0a4dc52274d37 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 10:26:46 +0200 Subject: [PATCH 08/31] feat(breadcrumb): copy note path to clipboard & recent changes in subtree options --- apps/client/src/widgets/layout/Breadcrumb.tsx | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index be09a1b78..7cac261f0 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -8,6 +8,7 @@ import NoteContext from "../../components/note_context"; import FNote from "../../entities/fnote"; import contextMenu from "../../menus/context_menu"; import link_context_menu from "../../menus/link_context_menu"; +import { copyTextWithToast } from "../../services/clipboard_ext"; import { getReadableTextColor } from "../../services/css_class_manager"; import froca from "../../services/froca"; import hoisted_note from "../../services/hoisted_note"; @@ -173,6 +174,7 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde if (!note) return; const notSearch = note?.type !== "search"; + const notOptionsOrHelp = !note?.noteId.startsWith("_options") && !note?.noteId.startsWith("_help"); const isNotRoot = note?.noteId !== "root"; const isHoisted = note?.noteId === appContext.tabManager.getActiveContext()?.hoistedNoteId; const parentNote = isNotRoot && branch ? await froca.getNote(branch.parentNoteId) : null; @@ -200,6 +202,9 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted }, + { kind: "separator" }, + { title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true }, + { title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: notOptionsOrHelp } ], x: e.pageX, y: e.pageY, @@ -208,12 +213,20 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde return; } - if (command) { - parentComponent?.triggerCommand(command, { - noteId, - selectedOrActiveBranchIds: [ branchId ], - selectedOrActiveNoteIds: [ noteId ] - }); + if (!command) return; + switch (command) { + case "copyNotePathToClipboard": + copyTextWithToast(`#${notePath}`); + break; + case "recentChangesInSubtree": + parentComponent?.triggerCommand("showRecentChanges", { ancestorNoteId: noteId }); + break; + default: + parentComponent?.triggerCommand(command, { + noteId, + selectedOrActiveBranchIds: [ branchId ], + selectedOrActiveNoteIds: [ noteId ] + }); } }, }); From d1575a28adfeb42bd85c80b7bfeec64fa1d2db87 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 10:34:36 +0200 Subject: [PATCH 09/31] feat(breadcrumb): add duplicate/archive/delete options --- apps/client/src/widgets/layout/Breadcrumb.tsx | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index 7cac261f0..000698252 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -8,11 +8,15 @@ import NoteContext from "../../components/note_context"; import FNote from "../../entities/fnote"; import contextMenu from "../../menus/context_menu"; import link_context_menu from "../../menus/link_context_menu"; +import attributes from "../../services/attributes"; +import branches from "../../services/branches"; +import { executeBulkActions } from "../../services/bulk_action"; import { copyTextWithToast } from "../../services/clipboard_ext"; import { getReadableTextColor } from "../../services/css_class_manager"; import froca from "../../services/froca"; import hoisted_note from "../../services/hoisted_note"; import { t } from "../../services/i18n"; +import note_create from "../../services/note_create"; import ActionButton from "../react/ActionButton"; import { Badge } from "../react/Badge"; import Dropdown from "../react/Dropdown"; @@ -169,12 +173,14 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde const branchId = await froca.getBranchId(parentNoteId, noteId); if (!branchId) return; const branch = froca.getBranch(branchId); + if (!branch) return; - const note = await froca.getNote(noteId); + const note = await branch?.getNote(); if (!note) return; const notSearch = note?.type !== "search"; const notOptionsOrHelp = !note?.noteId.startsWith("_options") && !note?.noteId.startsWith("_help"); + const isArchived = note.isArchived; const isNotRoot = note?.noteId !== "root"; const isHoisted = note?.noteId === appContext.tabManager.getActiveContext()?.hoistedNoteId; const parentNote = isNotRoot && branch ? await froca.getNote(branch.parentNoteId) : null; @@ -204,7 +210,34 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde }, { kind: "separator" }, { title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true }, - { title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: notOptionsOrHelp } + { title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: notOptionsOrHelp }, + { kind: "separator" }, + { + title: t("tree-context-menu.duplicate"), + command: "duplicateSubtree", + uiIcon: "bx bx-outline", + enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp && note.isContentAvailable(), + handler: () => note_create.duplicateSubtree(noteId, branch.parentNoteId) + }, + + { + title: !isArchived ? t("tree-context-menu.archive") : t("tree-context-menu.unarchive"), + uiIcon: !isArchived ? "bx bx-archive" : "bx bx-archive-out", + handler: () => { + if (!isArchived) { + attributes.addLabel(note.noteId, "archived"); + } else { + attributes.removeOwnedLabelByName(note, "archived"); + } + } + }, + { + title: t("tree-context-menu.delete"), + command: "deleteNotes", + uiIcon: "bx bx-trash destructive-action-icon", + enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp, + handler: () => branches.deleteNotes([ branchId ]) + } ], x: e.pageX, y: e.pageY, From d1820a6bc37a47edb298183045feec76b9f6243c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 10:39:22 +0200 Subject: [PATCH 10/31] feat(breadcrumb): color selector --- apps/client/src/menus/link_context_menu.ts | 6 +- apps/client/src/widgets/layout/Breadcrumb.tsx | 115 ++++++++++-------- 2 files changed, 66 insertions(+), 55 deletions(-) diff --git a/apps/client/src/menus/link_context_menu.ts b/apps/client/src/menus/link_context_menu.ts index 2cba3aab4..b9c99c612 100644 --- a/apps/client/src/menus/link_context_menu.ts +++ b/apps/client/src/menus/link_context_menu.ts @@ -16,7 +16,7 @@ function openContextMenu(notePath: string, e: ContextMenuEvent, viewScope: ViewS }); } -function getItems(e: ContextMenuEvent | LeafletMouseEvent): MenuItem[] { +function getItems(e: ContextMenuEvent | LeafletMouseEvent): MenuItem<"openNoteInNewTab" | "openNoteInNewSplit" | "openNoteInNewWindow" | "openNoteInPopup">[] { const ntxId = getNtxId(e); const isMobileSplitOpen = isMobile() && appContext.tabManager.getNoteContextById(ntxId).getMainContext().getSubContexts().length > 1; @@ -59,9 +59,9 @@ function getNtxId(e: ContextMenuEvent | LeafletMouseEvent) { return subContexts[subContexts.length - 1].ntxId; } else if (e.target instanceof HTMLElement) { return getClosestNtxId(e.target); - } + } return null; - + } export default { diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index 000698252..b00119f2e 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -3,11 +3,13 @@ import "./Breadcrumb.css"; import { useContext, useRef, useState } from "preact/hooks"; import { Fragment } from "preact/jsx-runtime"; -import appContext from "../../components/app_context"; +import appContext, { CommandNames } from "../../components/app_context"; import NoteContext from "../../components/note_context"; import FNote from "../../entities/fnote"; -import contextMenu from "../../menus/context_menu"; +import contextMenu, { MenuItem } from "../../menus/context_menu"; +import NoteColorPicker from "../../menus/custom-items/NoteColorPicker"; import link_context_menu from "../../menus/link_context_menu"; +import { TreeCommandNames } from "../../menus/tree_context_menu"; import attributes from "../../services/attributes"; import branches from "../../services/branches"; import { executeBulkActions } from "../../services/bulk_action"; @@ -186,59 +188,68 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde const parentNote = isNotRoot && branch ? await froca.getNote(branch.parentNoteId) : null; const parentNotSearch = !parentNote || parentNote.type !== "search"; - contextMenu.show({ - items: [ - ...link_context_menu.getItems(e), - { - title: `${t("tree-context-menu.hoist-note")}`, - command: "toggleNoteHoisting", - uiIcon: "bx bxs-chevrons-up", - enabled: notSearch - }, - { kind: "separator" }, - { - title: t("tree-context-menu.move-to"), - command: "moveNotesTo", - uiIcon: "bx bx-transfer", - enabled: isNotRoot && !isHoisted && parentNotSearch - }, - { - title: t("tree-context-menu.clone-to"), - command: "cloneNotesTo", - uiIcon: "bx bx-duplicate", - enabled: isNotRoot && !isHoisted - }, - { kind: "separator" }, - { title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true }, - { title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: notOptionsOrHelp }, - { kind: "separator" }, - { - title: t("tree-context-menu.duplicate"), - command: "duplicateSubtree", - uiIcon: "bx bx-outline", - enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp && note.isContentAvailable(), - handler: () => note_create.duplicateSubtree(noteId, branch.parentNoteId) - }, + const items = [ + ...link_context_menu.getItems(e), + { + title: `${t("tree-context-menu.hoist-note")}`, + command: "toggleNoteHoisting", + uiIcon: "bx bxs-chevrons-up", + enabled: notSearch + }, + { kind: "separator" }, + { + title: t("tree-context-menu.move-to"), + command: "moveNotesTo", + uiIcon: "bx bx-transfer", + enabled: isNotRoot && !isHoisted && parentNotSearch + }, + { + title: t("tree-context-menu.clone-to"), + command: "cloneNotesTo", + uiIcon: "bx bx-duplicate", + enabled: isNotRoot && !isHoisted + }, + { kind: "separator" }, + { title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true }, + { title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: notOptionsOrHelp }, + { kind: "separator" }, + { + title: t("tree-context-menu.duplicate"), + command: "duplicateSubtree", + uiIcon: "bx bx-outline", + enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp && note.isContentAvailable(), + handler: () => note_create.duplicateSubtree(noteId, branch.parentNoteId) + }, - { - title: !isArchived ? t("tree-context-menu.archive") : t("tree-context-menu.unarchive"), - uiIcon: !isArchived ? "bx bx-archive" : "bx bx-archive-out", - handler: () => { - if (!isArchived) { - attributes.addLabel(note.noteId, "archived"); - } else { - attributes.removeOwnedLabelByName(note, "archived"); - } + { + title: !isArchived ? t("tree-context-menu.archive") : t("tree-context-menu.unarchive"), + uiIcon: !isArchived ? "bx bx-archive" : "bx bx-archive-out", + handler: () => { + if (!isArchived) { + attributes.addLabel(note.noteId, "archived"); + } else { + attributes.removeOwnedLabelByName(note, "archived"); } - }, - { - title: t("tree-context-menu.delete"), - command: "deleteNotes", - uiIcon: "bx bx-trash destructive-action-icon", - enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp, - handler: () => branches.deleteNotes([ branchId ]) } - ], + }, + { + title: t("tree-context-menu.delete"), + command: "deleteNotes", + uiIcon: "bx bx-trash destructive-action-icon", + enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp, + handler: () => branches.deleteNotes([ branchId ]) + }, + { kind: "separator"}, + (notOptionsOrHelp ? { + kind: "custom", + componentFn: () => { + return NoteColorPicker({note}); + } + } : null), + ]; + + contextMenu.show({ + items: items.filter(Boolean) as MenuItem[], x: e.pageX, y: e.pageY, selectMenuItemHandler: ({ command }) => { From 7c85fe1c3744f9284b6e11586ea415869b012ea5 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 10:46:21 +0200 Subject: [PATCH 11/31] feat(breadcrumb): add search in subtree --- apps/client/src/widgets/layout/Breadcrumb.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index b00119f2e..ed2b2898b 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -3,7 +3,7 @@ import "./Breadcrumb.css"; import { useContext, useRef, useState } from "preact/hooks"; import { Fragment } from "preact/jsx-runtime"; -import appContext, { CommandNames } from "../../components/app_context"; +import appContext from "../../components/app_context"; import NoteContext from "../../components/note_context"; import FNote from "../../entities/fnote"; import contextMenu, { MenuItem } from "../../menus/context_menu"; @@ -12,7 +12,6 @@ import link_context_menu from "../../menus/link_context_menu"; import { TreeCommandNames } from "../../menus/tree_context_menu"; import attributes from "../../services/attributes"; import branches from "../../services/branches"; -import { executeBulkActions } from "../../services/bulk_action"; import { copyTextWithToast } from "../../services/clipboard_ext"; import { getReadableTextColor } from "../../services/css_class_manager"; import froca from "../../services/froca"; @@ -246,6 +245,13 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde return NoteColorPicker({note}); } } : null), + { kind: "separator" }, + { + title: t("tree-context-menu.search-in-subtree"), + command: "searchInSubtree", + uiIcon: "bx bx-search", + enabled: notSearch + } ]; contextMenu.show({ @@ -268,6 +274,7 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde default: parentComponent?.triggerCommand(command, { noteId, + notePath, selectedOrActiveBranchIds: [ branchId ], selectedOrActiveNoteIds: [ noteId ] }); From 15f9b2cadf53f0e23353ff0af6da70ccdd480480 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 10:52:03 +0200 Subject: [PATCH 12/31] feat(breadcrumb): add context menu on last item --- apps/client/src/widgets/layout/Breadcrumb.tsx | 261 +++++++++--------- 1 file changed, 134 insertions(+), 127 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index ed2b2898b..67775177e 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -4,6 +4,7 @@ import { useContext, useRef, useState } from "preact/hooks"; import { Fragment } from "preact/jsx-runtime"; import appContext from "../../components/app_context"; +import Component from "../../components/component"; import NoteContext from "../../components/note_context"; import FNote from "../../entities/fnote"; import contextMenu, { MenuItem } from "../../menus/context_menu"; @@ -33,6 +34,7 @@ const FINAL_ITEMS = 2; export default function Breadcrumb({ note, noteContext }: { note: FNote, noteContext: NoteContext }) { const notePath = buildNotePaths(noteContext); + const parentComponent = useContext(ParentComponent); return (
@@ -40,7 +42,7 @@ export default function Breadcrumb({ note, noteContext }: { note: FNote, noteCon <> {notePath.slice(0, INITIAL_ITEMS).map((item, index) => ( - + ))} @@ -48,7 +50,7 @@ export default function Breadcrumb({ note, noteContext }: { note: FNote, noteCon {notePath.slice(-FINAL_ITEMS).map((item, index) => ( - + ))} @@ -57,7 +59,7 @@ export default function Breadcrumb({ note, noteContext }: { note: FNote, noteCon {index === 0 ? - : + : } {(index < notePath.length - 1 || note?.hasChildren()) && } @@ -121,7 +123,7 @@ function BreadcrumbHoistedNoteRoot({ noteId }: { noteId: string }) { ); } -function BreadcrumbLastItem({ notePath }: { notePath: string }) { +function BreadcrumbLastItem({ notePath, parentComponent }: { notePath: string, parentComponent: Component | null }) { const linkRef = useRef(null); const noteId = notePath.split("/").at(-1); const [ note ] = useState(() => froca.getNoteFromCache(noteId!)); @@ -143,145 +145,26 @@ function BreadcrumbLastItem({ notePath }: { notePath: string }) { const scrollingContainer = document.querySelector(`[data-ntx-id="${activeNtxId}"] .scrolling-container`); scrollingContainer?.scrollTo({ top: 0, behavior: "smooth" }); }} + onContextMenu={buildContextMenu(notePath, parentComponent)} >{title} ); } -function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { index: number, notePathLength: number, notePath: string, noteContext: NoteContext | undefined }) { - const parentComponent = useContext(ParentComponent); - +function BreadcrumbItem({ index, notePath, noteContext, notePathLength, parentComponent }: { index: number, notePathLength: number, notePath: string, noteContext: NoteContext | undefined, parentComponent: Component | null }) { if (index === 0) { return ; } if (index === notePathLength - 1) { return <> - + ; } return { - e.preventDefault(); - - const notePathComponents = notePath.split("/"); - const parentNoteId = notePathComponents.at(-2); - const noteId = notePathComponents.at(-1); - if (!parentNoteId || !noteId) return; - - const branchId = await froca.getBranchId(parentNoteId, noteId); - if (!branchId) return; - const branch = froca.getBranch(branchId); - if (!branch) return; - - const note = await branch?.getNote(); - if (!note) return; - - const notSearch = note?.type !== "search"; - const notOptionsOrHelp = !note?.noteId.startsWith("_options") && !note?.noteId.startsWith("_help"); - const isArchived = note.isArchived; - const isNotRoot = note?.noteId !== "root"; - const isHoisted = note?.noteId === appContext.tabManager.getActiveContext()?.hoistedNoteId; - const parentNote = isNotRoot && branch ? await froca.getNote(branch.parentNoteId) : null; - const parentNotSearch = !parentNote || parentNote.type !== "search"; - - const items = [ - ...link_context_menu.getItems(e), - { - title: `${t("tree-context-menu.hoist-note")}`, - command: "toggleNoteHoisting", - uiIcon: "bx bxs-chevrons-up", - enabled: notSearch - }, - { kind: "separator" }, - { - title: t("tree-context-menu.move-to"), - command: "moveNotesTo", - uiIcon: "bx bx-transfer", - enabled: isNotRoot && !isHoisted && parentNotSearch - }, - { - title: t("tree-context-menu.clone-to"), - command: "cloneNotesTo", - uiIcon: "bx bx-duplicate", - enabled: isNotRoot && !isHoisted - }, - { kind: "separator" }, - { title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true }, - { title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: notOptionsOrHelp }, - { kind: "separator" }, - { - title: t("tree-context-menu.duplicate"), - command: "duplicateSubtree", - uiIcon: "bx bx-outline", - enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp && note.isContentAvailable(), - handler: () => note_create.duplicateSubtree(noteId, branch.parentNoteId) - }, - - { - title: !isArchived ? t("tree-context-menu.archive") : t("tree-context-menu.unarchive"), - uiIcon: !isArchived ? "bx bx-archive" : "bx bx-archive-out", - handler: () => { - if (!isArchived) { - attributes.addLabel(note.noteId, "archived"); - } else { - attributes.removeOwnedLabelByName(note, "archived"); - } - } - }, - { - title: t("tree-context-menu.delete"), - command: "deleteNotes", - uiIcon: "bx bx-trash destructive-action-icon", - enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp, - handler: () => branches.deleteNotes([ branchId ]) - }, - { kind: "separator"}, - (notOptionsOrHelp ? { - kind: "custom", - componentFn: () => { - return NoteColorPicker({note}); - } - } : null), - { kind: "separator" }, - { - title: t("tree-context-menu.search-in-subtree"), - command: "searchInSubtree", - uiIcon: "bx bx-search", - enabled: notSearch - } - ]; - - contextMenu.show({ - items: items.filter(Boolean) as MenuItem[], - x: e.pageX, - y: e.pageY, - selectMenuItemHandler: ({ command }) => { - if (link_context_menu.handleLinkContextMenuItem(command, e, note.noteId)) { - return; - } - - if (!command) return; - switch (command) { - case "copyNotePathToClipboard": - copyTextWithToast(`#${notePath}`); - break; - case "recentChangesInSubtree": - parentComponent?.triggerCommand("showRecentChanges", { ancestorNoteId: noteId }); - break; - default: - parentComponent?.triggerCommand(command, { - noteId, - notePath, - selectedOrActiveBranchIds: [ branchId ], - selectedOrActiveNoteIds: [ noteId ] - }); - } - }, - }); - }} + onContextMenu={buildContextMenu(notePath, parentComponent)} />; } @@ -379,3 +262,127 @@ function buildNotePaths(noteContext: NoteContext) { return output; } + +//#region Context menu +function buildContextMenu(notePath: string, parentComponent: Component | null) { + return async (e: MouseEvent) => { + e.preventDefault(); + + const notePathComponents = notePath.split("/"); + const parentNoteId = notePathComponents.at(-2); + const noteId = notePathComponents.at(-1); + if (!parentNoteId || !noteId) return; + + const branchId = await froca.getBranchId(parentNoteId, noteId); + if (!branchId) return; + const branch = froca.getBranch(branchId); + if (!branch) return; + + const note = await branch?.getNote(); + if (!note) return; + + const notSearch = note?.type !== "search"; + const notOptionsOrHelp = !note?.noteId.startsWith("_options") && !note?.noteId.startsWith("_help"); + const isArchived = note.isArchived; + const isNotRoot = note?.noteId !== "root"; + const isHoisted = note?.noteId === appContext.tabManager.getActiveContext()?.hoistedNoteId; + const parentNote = isNotRoot && branch ? await froca.getNote(branch.parentNoteId) : null; + const parentNotSearch = !parentNote || parentNote.type !== "search"; + + const items = [ + ...link_context_menu.getItems(e), + { + title: `${t("tree-context-menu.hoist-note")}`, + command: "toggleNoteHoisting", + uiIcon: "bx bxs-chevrons-up", + enabled: notSearch + }, + { kind: "separator" }, + { + title: t("tree-context-menu.move-to"), + command: "moveNotesTo", + uiIcon: "bx bx-transfer", + enabled: isNotRoot && !isHoisted && parentNotSearch + }, + { + title: t("tree-context-menu.clone-to"), + command: "cloneNotesTo", + uiIcon: "bx bx-duplicate", + enabled: isNotRoot && !isHoisted + }, + { kind: "separator" }, + { title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true }, + { title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: notOptionsOrHelp }, + { kind: "separator" }, + { + title: t("tree-context-menu.duplicate"), + command: "duplicateSubtree", + uiIcon: "bx bx-outline", + enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp && note.isContentAvailable(), + handler: () => note_create.duplicateSubtree(noteId, branch.parentNoteId) + }, + + { + title: !isArchived ? t("tree-context-menu.archive") : t("tree-context-menu.unarchive"), + uiIcon: !isArchived ? "bx bx-archive" : "bx bx-archive-out", + handler: () => { + if (!isArchived) { + attributes.addLabel(note.noteId, "archived"); + } else { + attributes.removeOwnedLabelByName(note, "archived"); + } + } + }, + { + title: t("tree-context-menu.delete"), + command: "deleteNotes", + uiIcon: "bx bx-trash destructive-action-icon", + enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp, + handler: () => branches.deleteNotes([ branchId ]) + }, + { kind: "separator"}, + (notOptionsOrHelp ? { + kind: "custom", + componentFn: () => { + return NoteColorPicker({note}); + } + } : null), + { kind: "separator" }, + { + title: t("tree-context-menu.search-in-subtree"), + command: "searchInSubtree", + uiIcon: "bx bx-search", + enabled: notSearch + } + ]; + + contextMenu.show({ + items: items.filter(Boolean) as MenuItem[], + x: e.pageX, + y: e.pageY, + selectMenuItemHandler: ({ command }) => { + if (link_context_menu.handleLinkContextMenuItem(command, e, note.noteId)) { + return; + } + + if (!command) return; + switch (command) { + case "copyNotePathToClipboard": + copyTextWithToast(`#${notePath}`); + break; + case "recentChangesInSubtree": + parentComponent?.triggerCommand("showRecentChanges", { ancestorNoteId: noteId }); + break; + default: + parentComponent?.triggerCommand(command, { + noteId, + notePath, + selectedOrActiveBranchIds: [ branchId ], + selectedOrActiveNoteIds: [ noteId ] + }); + } + }, + }); + }; +} +//#endregion From ec22fd9e996b1b2f7cfd1dfead4c85fbc9f6aeeb Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 10:56:00 +0200 Subject: [PATCH 13/31] refactor(breadcrumb): use existing function to parse note path --- apps/client/src/widgets/layout/Breadcrumb.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index 67775177e..a836f8a5f 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -19,6 +19,7 @@ import froca from "../../services/froca"; import hoisted_note from "../../services/hoisted_note"; import { t } from "../../services/i18n"; import note_create from "../../services/note_create"; +import tree from "../../services/tree"; import ActionButton from "../react/ActionButton"; import { Badge } from "../react/Badge"; import Dropdown from "../react/Dropdown"; @@ -268,9 +269,7 @@ function buildContextMenu(notePath: string, parentComponent: Component | null) { return async (e: MouseEvent) => { e.preventDefault(); - const notePathComponents = notePath.split("/"); - const parentNoteId = notePathComponents.at(-2); - const noteId = notePathComponents.at(-1); + const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath); if (!parentNoteId || !noteId) return; const branchId = await froca.getBranchId(parentNoteId, noteId); From c9025f23042291a41581e836fdf07420471e3bfa Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 12:42:50 +0200 Subject: [PATCH 14/31] fix(breadcrumb): not reacting to changes in note path --- apps/client/src/widgets/layout/Breadcrumb.tsx | 47 ++++++++++--------- apps/client/src/widgets/layout/StatusBar.tsx | 2 +- apps/client/src/widgets/react/hooks.tsx | 14 +++++- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index a836f8a5f..bc445068a 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -6,7 +6,6 @@ import { Fragment } from "preact/jsx-runtime"; import appContext from "../../components/app_context"; import Component from "../../components/component"; import NoteContext from "../../components/note_context"; -import FNote from "../../entities/fnote"; import contextMenu, { MenuItem } from "../../menus/context_menu"; import NoteColorPicker from "../../menus/custom-items/NoteColorPicker"; import link_context_menu from "../../menus/link_context_menu"; @@ -24,7 +23,7 @@ import ActionButton from "../react/ActionButton"; import { Badge } from "../react/Badge"; import Dropdown from "../react/Dropdown"; import { FormListItem } from "../react/FormList"; -import { useChildNotes, useNote, useNoteIcon, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useStaticTooltip } from "../react/hooks"; +import { useActiveNoteContext, useChildNotes, useNote, useNoteIcon, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useStaticTooltip } from "../react/hooks"; import Icon from "../react/Icon"; import NoteLink from "../react/NoteLink"; import { ParentComponent } from "../react/react_utils"; @@ -33,37 +32,37 @@ const COLLAPSE_THRESHOLD = 5; const INITIAL_ITEMS = 2; const FINAL_ITEMS = 2; -export default function Breadcrumb({ note, noteContext }: { note: FNote, noteContext: NoteContext }) { - const notePath = buildNotePaths(noteContext); +export default function Breadcrumb() { + const { note, notePaths, noteContext } = useNotePaths(); const parentComponent = useContext(ParentComponent); return (
- {notePath.length > COLLAPSE_THRESHOLD ? ( + {notePaths.length > COLLAPSE_THRESHOLD ? ( <> - {notePath.slice(0, INITIAL_ITEMS).map((item, index) => ( + {notePaths.slice(0, INITIAL_ITEMS).map((item, index) => ( - - + + ))} - - {notePath.slice(-FINAL_ITEMS).map((item, index) => ( + + {notePaths.slice(-FINAL_ITEMS).map((item, index) => ( - - + + ))} ) : ( - notePath.map((item, index) => ( + notePaths.map((item, index) => ( {index === 0 ? - : + : } - {(index < notePath.length - 1 || note?.hasChildren()) && - } + {(index < notePaths.length - 1 || note?.hasChildren()) && + } )) )} @@ -239,16 +238,16 @@ function BreadcrumbCollapsed({ items, noteContext }: { items: string[], noteCont ); } -function buildNotePaths(noteContext: NoteContext) { - const notePathArray = noteContext.notePathArray; - if (!notePathArray) return []; +function useNotePaths() { + const { note, notePath, hoistedNoteId, noteContext } = useActiveNoteContext(); + const notePathArray = (notePath ?? "").split("/"); let prefix = ""; let output: string[] = []; let pos = 0; let hoistedNotePos = -1; for (const notePath of notePathArray) { - if (noteContext.hoistedNoteId !== "root" && notePath === noteContext.hoistedNoteId) { + if (hoistedNoteId !== "root" && notePath === hoistedNoteId) { hoistedNotePos = pos; } output.push(`${prefix}${notePath}`); @@ -257,11 +256,15 @@ function buildNotePaths(noteContext: NoteContext) { } // When hoisted, display only the path starting with the hoisted note. - if (noteContext.hoistedNoteId !== "root" && hoistedNotePos > -1) { + if (hoistedNoteId !== "root" && hoistedNotePos > -1) { output = output.slice(hoistedNotePos); } - return output; + return { + note, + notePaths: output, + noteContext + }; } //#region Context menu diff --git a/apps/client/src/widgets/layout/StatusBar.tsx b/apps/client/src/widgets/layout/StatusBar.tsx index 4d3a273cf..9b99524b0 100644 --- a/apps/client/src/widgets/layout/StatusBar.tsx +++ b/apps/client/src/widgets/layout/StatusBar.tsx @@ -64,7 +64,7 @@ export default function StatusBar() {
{context && attributesContext && noteInfoContext && <> - +
diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index d01fb8156..36c142505 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -20,6 +20,7 @@ import server from "../../services/server"; import shortcuts, { Handler, removeIndividualBinding } from "../../services/shortcuts"; import SpacedUpdate from "../../services/spaced_update"; import toast, { ToastOptions } from "../../services/toast"; +import tree from "../../services/tree"; import utils, { escapeRegExp, randomString, reloadFrontendApp } from "../../services/utils"; import BasicWidget, { ReactWrappedWidget } from "../basic_widget"; import NoteContextAwareWidget from "../note_context_aware_widget"; @@ -386,6 +387,16 @@ export function useActiveNoteContext() { setHoistedNoteId(noteId); } }); + /** + * Note context doesn't actually refresh at all if the active note is moved around (e.g. the note path changes). + * Address that by listening to note changes. + */ + useTriliumEvent("entitiesReloaded", async ({ loadResults }) => { + if (note && notePath && loadResults.getBranchRows().some(b => b.noteId === note.noteId)) { + const resolvedNotePath = await tree.resolveNotePath(notePath, hoistedNoteId); + setNotePath(resolvedNotePath); + } + }); const parentComponent = useContext(ParentComponent) as ReactWrappedWidget; useDebugValue(() => `notePath=${notePath}, ntxId=${noteContext?.ntxId}`); @@ -393,7 +404,8 @@ export function useActiveNoteContext() { return { note, noteId: noteContext?.note?.noteId, - notePath: noteContext?.notePath, + /** The note path of the note context. Unlike `noteContext.notePath`, this one actually reacts to the active note being moved around. */ + notePath, hoistedNoteId, ntxId: noteContext?.ntxId, viewScope: noteContext?.viewScope, From 34343ce356627fcde3297583c307948170acc68b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 12:53:51 +0200 Subject: [PATCH 15/31] fix(server): autocomplete shows empty name for hoisted note --- apps/server/src/routes/api/autocomplete.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/server/src/routes/api/autocomplete.ts b/apps/server/src/routes/api/autocomplete.ts index 459555968..9915f58cb 100644 --- a/apps/server/src/routes/api/autocomplete.ts +++ b/apps/server/src/routes/api/autocomplete.ts @@ -1,21 +1,20 @@ -"use strict"; - -import beccaService from "../../becca/becca_service.js"; -import searchService from "../../services/search/services/search.js"; -import log from "../../services/log.js"; -import utils from "../../services/utils.js"; -import cls from "../../services/cls.js"; -import becca from "../../becca/becca.js"; import type { Request } from "express"; + +import becca from "../../becca/becca.js"; +import beccaService from "../../becca/becca_service.js"; import ValidationError from "../../errors/validation_error.js"; +import cls from "../../services/cls.js"; +import log from "../../services/log.js"; +import searchService from "../../services/search/services/search.js"; import sql from "../../services/sql.js"; +import utils from "../../services/utils.js"; function getAutocomplete(req: Request) { if (typeof req.query.query !== "string") { throw new ValidationError("Invalid query data type."); } const query = (req.query.query || "").trim(); - const fastSearch = String(req.query.fastSearch).toLowerCase() === "false" ? false : true; + const fastSearch = String(req.query.fastSearch).toLowerCase() !== "false"; const activeNoteId = req.query.activeNoteId || "none"; @@ -75,7 +74,7 @@ function getRecentNotes(activeNoteId: string) { notePath: rn.notePath, noteTitle: title, notePathTitle, - highlightedNotePathTitle: utils.escapeHtml(notePathTitle), + highlightedNotePathTitle: utils.escapeHtml(notePathTitle || title), icon: icon ?? "bx bx-note" }; }); From 2693b18ee61d409629e01592338cf6b6e4f5b147 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 13:31:17 +0200 Subject: [PATCH 16/31] refactor(breadcrumb): use new component for rendering note links --- apps/client/src/services/link.ts | 37 +++++++++-------- apps/client/src/widgets/layout/Breadcrumb.tsx | 6 +-- apps/client/src/widgets/react/NoteLink.tsx | 41 ++++++++++++++++++- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/apps/client/src/services/link.ts b/apps/client/src/services/link.ts index 4ff3a39cf..03595920b 100644 --- a/apps/client/src/services/link.ts +++ b/apps/client/src/services/link.ts @@ -1,10 +1,11 @@ -import treeService from "./tree.js"; -import linkContextMenuService from "../menus/link_context_menu.js"; -import appContext, { type NoteCommandData } from "../components/app_context.js"; -import froca from "./froca.js"; -import utils from "./utils.js"; import { ALLOWED_PROTOCOLS } from "@triliumnext/commons"; + +import appContext, { type NoteCommandData } from "../components/app_context.js"; import { openInCurrentNoteContext } from "../components/note_context.js"; +import linkContextMenuService from "../menus/link_context_menu.js"; +import froca from "./froca.js"; +import treeService from "./tree.js"; +import utils from "./utils.js"; function getNotePathFromUrl(url: string) { const notePathMatch = /#(root[A-Za-z0-9_/]*)$/.exec(url); @@ -122,7 +123,7 @@ async function createLink(notePath: string | undefined, options: CreateLinkOptio const $container = $(""); if (showNoteIcon) { - let icon = await getLinkIcon(noteId, viewMode); + const icon = await getLinkIcon(noteId, viewMode); if (icon) { $container.append($("").addClass(`bx ${icon}`)).append(" "); @@ -131,7 +132,7 @@ async function createLink(notePath: string | undefined, options: CreateLinkOptio const hash = calculateHash({ notePath, - viewScope: viewScope + viewScope }); const $noteLink = $("", { @@ -171,11 +172,11 @@ async function createLink(notePath: string | undefined, options: CreateLinkOptio return $container; } -function calculateHash({ notePath, ntxId, hoistedNoteId, viewScope = {} }: NoteCommandData) { +export function calculateHash({ notePath, ntxId, hoistedNoteId, viewScope = {} }: NoteCommandData) { notePath = notePath || ""; const params = [ - ntxId ? { ntxId: ntxId } : null, - hoistedNoteId && hoistedNoteId !== "root" ? { hoistedNoteId: hoistedNoteId } : null, + ntxId ? { ntxId } : null, + hoistedNoteId && hoistedNoteId !== "root" ? { hoistedNoteId } : null, viewScope.viewMode && viewScope.viewMode !== "default" ? { viewMode: viewScope.viewMode } : null, viewScope.attachmentId ? { attachmentId: viewScope.attachmentId } : null ].filter((p) => !!p); @@ -219,7 +220,7 @@ export function parseNavigationStateFromUrl(url: string | undefined) { } const hash = url.substr(hashIdx + 1); // strip also the initial '#' - let [notePath, paramString] = hash.split("?"); + const [notePath, paramString] = hash.split("?"); const viewScope: ViewScope = { viewMode: "default" @@ -252,7 +253,7 @@ export function parseNavigationStateFromUrl(url: string | undefined) { } if (searchString) { - return { searchString } + return { searchString }; } if (!notePath.match(/^[_a-z0-9]{4,}(\/[_a-z0-9]{4,})*$/i)) { @@ -334,7 +335,7 @@ export function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDo window.open(hrefLink, "_blank"); } else { // Enable protocols supported by CKEditor 5 to be clickable. - if (ALLOWED_PROTOCOLS.some((protocol) => hrefLink.toLowerCase().startsWith(protocol + ":"))) { + if (ALLOWED_PROTOCOLS.some((protocol) => hrefLink.toLowerCase().startsWith(`${protocol }:`))) { if ( utils.isElectron()) { const electron = utils.dynamicRequire("electron"); electron.shell.openExternal(hrefLink); @@ -395,7 +396,7 @@ async function loadReferenceLinkTitle($el: JQuery, href: string | n href = href || $link.attr("href"); if (!href) { - console.warn("Empty URL for parsing: " + $el[0].outerHTML); + console.warn(`Empty URL for parsing: ${$el[0].outerHTML}`); return; } @@ -438,9 +439,9 @@ async function getReferenceLinkTitle(href: string) { const attachment = await note.getAttachmentById(viewScope.attachmentId); return attachment ? attachment.title : "[missing attachment]"; - } else { - return note.title; } + return note.title; + } function getReferenceLinkTitleSync(href: string) { @@ -462,9 +463,9 @@ function getReferenceLinkTitleSync(href: string) { const attachment = note.attachments.find((att) => att.attachmentId === viewScope.attachmentId); return attachment ? attachment.title : "[missing attachment]"; - } else { - return note.title; } + return note.title; + } if (glob.device !== "print") { diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index bc445068a..780ae0d4b 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -25,7 +25,7 @@ import Dropdown from "../react/Dropdown"; import { FormListItem } from "../react/FormList"; import { useActiveNoteContext, useChildNotes, useNote, useNoteIcon, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useStaticTooltip } from "../react/hooks"; import Icon from "../react/Icon"; -import NoteLink from "../react/NoteLink"; +import { NewNoteLink } from "../react/NoteLink"; import { ParentComponent } from "../react/react_utils"; const COLLAPSE_THRESHOLD = 5; @@ -114,7 +114,7 @@ function BreadcrumbHoistedNoteRoot({ noteId }: { noteId: string }) { "color": getReadableTextColor(workspaceColor) } : undefined} /> - ; } - return ; } + +interface NewNoteLinkProps extends Pick, "onContextMenu"> { + notePath: string; + viewScope?: ViewScope; + noContextMenu?: boolean; + showNoteIcon?: boolean; + noPreview?: boolean; +} + +export function NewNoteLink({ notePath, viewScope, noContextMenu, showNoteIcon, noPreview, ...linkProps }: NewNoteLinkProps) { + const noteId = notePath.split("/").at(-1); + const note = useNote(noteId); + const title = useNoteProperty(note, "title"); + const icon = useNoteIcon(showNoteIcon ? note : null); + + return ( + + + {icon && } + + + {title} + + + + ); +} From 0af5fa9f0c7cd6d0d6a53130b7a679166be72d01 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 13:40:22 +0200 Subject: [PATCH 17/31] feat(breadcrumb): respect note color --- apps/client/src/widgets/layout/Breadcrumb.css | 2 +- apps/client/src/widgets/react/NoteLink.tsx | 5 +++-- apps/client/src/widgets/react/hooks.tsx | 9 +++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.css b/apps/client/src/widgets/layout/Breadcrumb.css index 436b9cb0f..c4de82685 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.css +++ b/apps/client/src/widgets/layout/Breadcrumb.css @@ -26,7 +26,7 @@ } a { - color: inherit; + color: var(--custom-color, inherit); text-decoration: none; min-width: 0; max-width: 150px; diff --git a/apps/client/src/widgets/react/NoteLink.tsx b/apps/client/src/widgets/react/NoteLink.tsx index 3664acce7..6ae05f38c 100644 --- a/apps/client/src/widgets/react/NoteLink.tsx +++ b/apps/client/src/widgets/react/NoteLink.tsx @@ -3,7 +3,7 @@ import { HTMLAttributes } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import link, { calculateHash, ViewScope } from "../../services/link"; -import { useImperativeSearchHighlighlighting, useNote, useNoteIcon, useNoteProperty, useTriliumEvent } from "./hooks"; +import { useImperativeSearchHighlighlighting, useNote, useNoteColorClass, useNoteIcon, useNoteProperty, useTriliumEvent } from "./hooks"; import Icon from "./Icon"; interface NoteLinkOpts { @@ -100,6 +100,7 @@ export function NewNoteLink({ notePath, viewScope, noContextMenu, showNoteIcon, const note = useNote(noteId); const title = useNoteProperty(note, "title"); const icon = useNoteIcon(showNoteIcon ? note : null); + const colorClass = useNoteColorClass(note); return ( @@ -107,7 +108,7 @@ export function NewNoteLink({ notePath, viewScope, noContextMenu, showNoteIcon, {icon && } { + setColorClass(note?.getColorClass()); + }, [ color, note ]); + return colorClass; +} From 79d1a509e53cad7a7561609b6913f35d00cf8f1e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 13:42:41 +0200 Subject: [PATCH 18/31] feat(breadcrumb): respect note color in last item --- apps/client/src/widgets/layout/Breadcrumb.css | 2 +- apps/client/src/widgets/layout/Breadcrumb.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.css b/apps/client/src/widgets/layout/Breadcrumb.css index c4de82685..fb6ef5842 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.css +++ b/apps/client/src/widgets/layout/Breadcrumb.css @@ -63,7 +63,7 @@ a.breadcrumb-last-item, a.breadcrumb-last-item:visited { text-decoration: none; - color: currentColor; + color: var(--custom-color, currentColor); font-weight: 600; } diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index 780ae0d4b..72ad55988 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -1,5 +1,6 @@ import "./Breadcrumb.css"; +import clsx from "clsx"; import { useContext, useRef, useState } from "preact/hooks"; import { Fragment } from "preact/jsx-runtime"; @@ -23,7 +24,7 @@ import ActionButton from "../react/ActionButton"; import { Badge } from "../react/Badge"; import Dropdown from "../react/Dropdown"; import { FormListItem } from "../react/FormList"; -import { useActiveNoteContext, useChildNotes, useNote, useNoteIcon, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useStaticTooltip } from "../react/hooks"; +import { useActiveNoteContext, useChildNotes, useNote, useNoteColorClass, useNoteIcon, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useStaticTooltip } from "../react/hooks"; import Icon from "../react/Icon"; import { NewNoteLink } from "../react/NoteLink"; import { ParentComponent } from "../react/react_utils"; @@ -128,6 +129,7 @@ function BreadcrumbLastItem({ notePath, parentComponent }: { notePath: string, p const noteId = notePath.split("/").at(-1); const [ note ] = useState(() => froca.getNoteFromCache(noteId!)); const title = useNoteProperty(note, "title"); + const colorClass = useNoteColorClass(note); useStaticTooltip(linkRef, { placement: "top", title: t("breadcrumb.scroll_to_top_title") @@ -139,7 +141,7 @@ function BreadcrumbLastItem({ notePath, parentComponent }: { notePath: string, p { const activeNtxId = appContext.tabManager.activeNtxId; const scrollingContainer = document.querySelector(`[data-ntx-id="${activeNtxId}"] .scrolling-container`); From d97b68fcd77d68711b95394087bdfc5cdc4cfc85 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 13:43:40 +0200 Subject: [PATCH 19/31] feat(breadcrumb): maintain note color on hover --- apps/client/src/widgets/layout/Breadcrumb.css | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.css b/apps/client/src/widgets/layout/Breadcrumb.css index fb6ef5842..d003c2dc4 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.css +++ b/apps/client/src/widgets/layout/Breadcrumb.css @@ -15,6 +15,14 @@ color: var(--main-text-color); } + a.tn-link { + color: var(--custom-color, inherit); + + &:hover { + color: var(--custom-color, inherit); + } + } + > span, > span > span { display: flex; @@ -26,7 +34,6 @@ } a { - color: var(--custom-color, inherit); text-decoration: none; min-width: 0; max-width: 150px; @@ -63,7 +70,6 @@ a.breadcrumb-last-item, a.breadcrumb-last-item:visited { text-decoration: none; - color: var(--custom-color, currentColor); font-weight: 600; } From f235839d03c0e253b31f79802d1cfc2e42f08b6c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 13:50:53 +0200 Subject: [PATCH 20/31] feat(breadcrumb): indicate archived notes --- apps/client/src/widgets/layout/Breadcrumb.css | 4 ++++ apps/client/src/widgets/react/NoteLink.tsx | 7 +++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.css b/apps/client/src/widgets/layout/Breadcrumb.css index d003c2dc4..626169d45 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.css +++ b/apps/client/src/widgets/layout/Breadcrumb.css @@ -21,6 +21,10 @@ &:hover { color: var(--custom-color, inherit); } + + &.archived { + opacity: 0.6; + } } > span, diff --git a/apps/client/src/widgets/react/NoteLink.tsx b/apps/client/src/widgets/react/NoteLink.tsx index 6ae05f38c..a415c8b86 100644 --- a/apps/client/src/widgets/react/NoteLink.tsx +++ b/apps/client/src/widgets/react/NoteLink.tsx @@ -3,7 +3,7 @@ import { HTMLAttributes } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import link, { calculateHash, ViewScope } from "../../services/link"; -import { useImperativeSearchHighlighlighting, useNote, useNoteColorClass, useNoteIcon, useNoteProperty, useTriliumEvent } from "./hooks"; +import { useImperativeSearchHighlighlighting, useNote, useNoteColorClass, useNoteIcon, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumEvent } from "./hooks"; import Icon from "./Icon"; interface NoteLinkOpts { @@ -88,6 +88,7 @@ export default function NoteLink({ className, containerClassName, notePath, show } interface NewNoteLinkProps extends Pick, "onContextMenu"> { + className?: string; notePath: string; viewScope?: ViewScope; noContextMenu?: boolean; @@ -101,6 +102,7 @@ export function NewNoteLink({ notePath, viewScope, noContextMenu, showNoteIcon, const title = useNoteProperty(note, "title"); const icon = useNoteIcon(showNoteIcon ? note : null); const colorClass = useNoteColorClass(note); + const [ archived ] = useNoteLabelBoolean(note, "archived"); return ( @@ -109,7 +111,8 @@ export function NewNoteLink({ notePath, viewScope, noContextMenu, showNoteIcon, Date: Tue, 16 Dec 2025 14:06:12 +0200 Subject: [PATCH 21/31] chore(client): improve error handling message in events --- apps/client/src/components/component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/client/src/components/component.ts b/apps/client/src/components/component.ts index 9a59b96be..56c9e7b79 100644 --- a/apps/client/src/components/component.ts +++ b/apps/client/src/components/component.ts @@ -65,8 +65,8 @@ export class TypedComponent> { // don't create promises if not needed (optimization) return callMethodPromise && childrenPromise ? Promise.all([callMethodPromise, childrenPromise]) : callMethodPromise || childrenPromise; - } catch (e: any) { - console.error(`Handling of event '${name}' failed in ${this.constructor.name} with error ${e.message} ${e.stack}`); + } catch (e: unknown) { + console.error(`Handling of event '${name}' failed in ${this.constructor.name} with error`, e); return null; } From 94df5c9126d182577b181edbf4e4c52169a39ddf Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 15:02:16 +0200 Subject: [PATCH 22/31] feat(breadcrumb): respect note color class in context menu --- apps/client/src/widgets/layout/Breadcrumb.css | 1 + apps/client/src/widgets/layout/Breadcrumb.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/client/src/widgets/layout/Breadcrumb.css b/apps/client/src/widgets/layout/Breadcrumb.css index 626169d45..494483e0d 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.css +++ b/apps/client/src/widgets/layout/Breadcrumb.css @@ -69,6 +69,7 @@ overflow: hidden; display: block; max-width: 300px; + color: var(--custom-color, inherit) !important; } a.breadcrumb-last-item, diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index 72ad55988..6454aea19 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -198,6 +198,7 @@ function BreadcrumbSeparatorDropdownContent({ notePath, noteContext, activeNoteP return
  • noteContext?.setNote(childNotePath)} > {childNotePath !== activeNotePath From 66ed88c409310b6d28267eef793da3973dc2af41 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 15:03:47 +0200 Subject: [PATCH 23/31] fix(breadcrumb): archived not respected by last item --- apps/client/src/widgets/layout/Breadcrumb.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index 6454aea19..c4e51b512 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -130,6 +130,7 @@ function BreadcrumbLastItem({ notePath, parentComponent }: { notePath: string, p const [ note ] = useState(() => froca.getNoteFromCache(noteId!)); const title = useNoteProperty(note, "title"); const colorClass = useNoteColorClass(note); + const [ archived ] = useNoteLabelBoolean(note, "archived"); useStaticTooltip(linkRef, { placement: "top", title: t("breadcrumb.scroll_to_top_title") @@ -141,7 +142,7 @@ function BreadcrumbLastItem({ notePath, parentComponent }: { notePath: string, p { const activeNtxId = appContext.tabManager.activeNtxId; const scrollingContainer = document.querySelector(`[data-ntx-id="${activeNtxId}"] .scrolling-container`); From 5449d033bf9594483990024e84ebeb5a84e05d8a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 15:05:33 +0200 Subject: [PATCH 24/31] feat(breadcrumb): indicate archived in separator menu --- apps/client/src/widgets/layout/Breadcrumb.css | 6 +++--- apps/client/src/widgets/layout/Breadcrumb.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.css b/apps/client/src/widgets/layout/Breadcrumb.css index 494483e0d..76d5095c0 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.css +++ b/apps/client/src/widgets/layout/Breadcrumb.css @@ -21,10 +21,10 @@ &:hover { color: var(--custom-color, inherit); } + } - &.archived { - opacity: 0.6; - } + .archived { + opacity: 0.6; } > span, diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index c4e51b512..aa7e484dc 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -199,7 +199,7 @@ function BreadcrumbSeparatorDropdownContent({ notePath, noteContext, activeNoteP return
  • noteContext?.setNote(childNotePath)} > {childNotePath !== activeNotePath From 3a4cff6529d0e52c9d3878db6bf17196f34d61bf Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 15:35:14 +0200 Subject: [PATCH 25/31] feat(breadcrumb): allow creating notes from separator menu --- apps/client/src/translations/en/translation.json | 3 ++- apps/client/src/widgets/layout/Breadcrumb.tsx | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index f6f167996..db876bee0 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2148,7 +2148,8 @@ "hoisted_badge": "Hoisted", "hoisted_badge_title": "Unhoist", "workspace_badge": "Workspace", - "scroll_to_top_title": "Jump to the beginning of the note" + "scroll_to_top_title": "Jump to the beginning of the note", + "create_new_note": "Create new child note" }, "breadcrumb_badges": { "read_only_explicit": "Read-only", diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index aa7e484dc..aeb0d8d23 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -23,7 +23,7 @@ import tree from "../../services/tree"; import ActionButton from "../react/ActionButton"; import { Badge } from "../react/Badge"; import Dropdown from "../react/Dropdown"; -import { FormListItem } from "../react/FormList"; +import { FormDropdownDivider, FormListItem } from "../react/FormList"; import { useActiveNoteContext, useChildNotes, useNote, useNoteColorClass, useNoteIcon, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useStaticTooltip } from "../react/hooks"; import Icon from "../react/Icon"; import { NewNoteLink } from "../react/NoteLink"; @@ -208,6 +208,12 @@ function BreadcrumbSeparatorDropdownContent({ notePath, noteContext, activeNoteP
  • ; })} + + + note_create.createNote(notePath, { activate: true })} + >{t("breadcrumb.create_new_note")} ); } From 193c9d8fa616e504361f2c17c7551611b0b6f6c2 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 16:00:23 +0200 Subject: [PATCH 26/31] feat(breadcrumb): option to hide archived notes --- .../src/translations/en/translation.json | 3 +- apps/client/src/widgets/layout/Breadcrumb.css | 5 ++++ apps/client/src/widgets/layout/Breadcrumb.tsx | 30 ++++++++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index db876bee0..10a3cb8cb 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2149,7 +2149,8 @@ "hoisted_badge_title": "Unhoist", "workspace_badge": "Workspace", "scroll_to_top_title": "Jump to the beginning of the note", - "create_new_note": "Create new child note" + "create_new_note": "Create new child note", + "empty_hide_archived_notes": "Hide archived notes" }, "breadcrumb_badges": { "read_only_explicit": "Read-only", diff --git a/apps/client/src/widgets/layout/Breadcrumb.css b/apps/client/src/widgets/layout/Breadcrumb.css index 76d5095c0..947f8266e 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.css +++ b/apps/client/src/widgets/layout/Breadcrumb.css @@ -82,4 +82,9 @@ padding: 0 10px; width: 200px; } + + & > .filler { + flex-grow: 1; + height: 23px; + } } diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index aeb0d8d23..f51ab8fdc 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -19,6 +19,7 @@ import froca from "../../services/froca"; import hoisted_note from "../../services/hoisted_note"; import { t } from "../../services/i18n"; import note_create from "../../services/note_create"; +import options from "../../services/options"; import tree from "../../services/tree"; import ActionButton from "../react/ActionButton"; import { Badge } from "../react/Badge"; @@ -67,6 +68,11 @@ export default function Breadcrumb() { )) )} + +
    ); } @@ -277,7 +283,7 @@ function useNotePaths() { }; } -//#region Context menu +//#region Note Context menu function buildContextMenu(notePath: string, parentComponent: Component | null) { return async (e: MouseEvent) => { e.preventDefault(); @@ -398,3 +404,25 @@ function buildContextMenu(notePath: string, parentComponent: Component | null) { }; } //#endregion + +//#region Empty context menu +function buildEmptyAreaContextMenu() { + return (e: MouseEvent) => { + const hideArchivedNotes = (options.get("hideArchivedNotes_main") === "true"); + + e.preventDefault(); + contextMenu.show({ + items: [ + { + title: t("breadcrumb.empty_hide_archived_notes"), + handler: () => options.save("hideArchivedNotes_main", !hideArchivedNotes ? "true" : "false"), + checked: hideArchivedNotes + } + ], + x: e.pageX, + y: e.pageY, + selectMenuItemHandler: () => {} + }); + }; +} +//#endregion From 0390fd3174febc707cd083ab74074a2a36fc1dd1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 16:03:09 +0200 Subject: [PATCH 27/31] fix(breadcrumb): hiding archived notes doesn't update tree --- apps/client/src/widgets/layout/Breadcrumb.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index f51ab8fdc..ac7ddc657 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -71,7 +71,7 @@ export default function Breadcrumb() {
    ); @@ -406,7 +406,7 @@ function buildContextMenu(notePath: string, parentComponent: Component | null) { //#endregion //#region Empty context menu -function buildEmptyAreaContextMenu() { +function buildEmptyAreaContextMenu(parentComponent: Component | null) { return (e: MouseEvent) => { const hideArchivedNotes = (options.get("hideArchivedNotes_main") === "true"); @@ -415,7 +415,12 @@ function buildEmptyAreaContextMenu() { items: [ { title: t("breadcrumb.empty_hide_archived_notes"), - handler: () => options.save("hideArchivedNotes_main", !hideArchivedNotes ? "true" : "false"), + handler: async () => { + await options.save("hideArchivedNotes_main", !hideArchivedNotes ? "true" : "false"); + + // Note tree doesn't update by itself. + parentComponent?.triggerEvent("frocaReloaded", {}); + }, checked: hideArchivedNotes } ], From d28c3f08518784fcfb67997ba804d4a75c26afdf Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 16:08:41 +0200 Subject: [PATCH 28/31] feat(breadcrumb): hide archived notes if needed --- apps/client/src/widgets/layout/Breadcrumb.tsx | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index ac7ddc657..46f562e9e 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -25,7 +25,7 @@ import ActionButton from "../react/ActionButton"; import { Badge } from "../react/Badge"; import Dropdown from "../react/Dropdown"; import { FormDropdownDivider, FormListItem } from "../react/FormList"; -import { useActiveNoteContext, useChildNotes, useNote, useNoteColorClass, useNoteIcon, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useStaticTooltip } from "../react/hooks"; +import { useActiveNoteContext, useChildNotes, useNote, useNoteColorClass, useNoteIcon, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useStaticTooltip, useTriliumOptionBool } from "../react/hooks"; import Icon from "../react/Icon"; import { NewNoteLink } from "../react/NoteLink"; import { ParentComponent } from "../react/react_utils"; @@ -37,6 +37,8 @@ const FINAL_ITEMS = 2; export default function Breadcrumb() { const { note, notePaths, noteContext } = useNotePaths(); const parentComponent = useContext(ParentComponent); + const [ hideArchivedNotes ] = useTriliumOptionBool("hideArchivedNotes_main"); + const separatorProps: Omit = { noteContext, hideArchivedNotes }; return (
    @@ -45,13 +47,13 @@ export default function Breadcrumb() { {notePaths.slice(0, INITIAL_ITEMS).map((item, index) => ( - + ))} {notePaths.slice(-FINAL_ITEMS).map((item, index) => ( - + ))} @@ -64,7 +66,7 @@ export default function Breadcrumb() { : } {(index < notePaths.length - 1 || note?.hasChildren()) && - } + } )) )} @@ -177,7 +179,14 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength, parentCo />; } -function BreadcrumbSeparator({ notePath, noteContext, activeNotePath }: { notePath: string, activeNotePath: string, noteContext: NoteContext | undefined }) { +interface BreadcrumbSeparatorProps { + notePath: string, + activeNotePath: string, + noteContext: NoteContext | undefined, + hideArchivedNotes: boolean; +} + +function BreadcrumbSeparator(props: BreadcrumbSeparatorProps) { return ( } @@ -186,12 +195,12 @@ function BreadcrumbSeparator({ notePath, noteContext, activeNotePath }: { notePa hideToggleArrow dropdownOptions={{ popperConfig: { strategy: "fixed", placement: "top" } }} > - + ); } -function BreadcrumbSeparatorDropdownContent({ notePath, noteContext, activeNotePath }: { notePath: string, activeNotePath: string, noteContext: NoteContext | undefined }) { +function BreadcrumbSeparatorDropdownContent({ notePath, noteContext, activeNotePath, hideArchivedNotes }: BreadcrumbSeparatorProps) { const notePathComponents = notePath.split("/"); const parentNoteId = notePathComponents.at(-1); const childNotes = useChildNotes(parentNoteId); @@ -200,6 +209,7 @@ function BreadcrumbSeparatorDropdownContent({ notePath, noteContext, activeNoteP
      {childNotes.map((note) => { if (note.noteId === "_hidden") return; + if (hideArchivedNotes && note.isArchived) return null; const childNotePath = `${notePath}/${note.noteId}`; return
    • @@ -224,7 +234,10 @@ function BreadcrumbSeparatorDropdownContent({ notePath, noteContext, activeNoteP ); } -function BreadcrumbCollapsed({ items, noteContext }: { items: string[], noteContext: NoteContext | undefined }) { +function BreadcrumbCollapsed({ items, noteContext }: { + items: string[], + noteContext: NoteContext | undefined, +}) { return ( } From 7e07280eb388c2a412b3e6107c9931c3847b6cc3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 16:14:08 +0200 Subject: [PATCH 29/31] feat(breadcrumb): relocate copy note path to empty area --- apps/client/src/widgets/layout/Breadcrumb.tsx | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index 46f562e9e..d3d790080 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -35,7 +35,7 @@ const INITIAL_ITEMS = 2; const FINAL_ITEMS = 2; export default function Breadcrumb() { - const { note, notePaths, noteContext } = useNotePaths(); + const { note, notePath, notePaths, noteContext } = useNotePaths(); const parentComponent = useContext(ParentComponent); const [ hideArchivedNotes ] = useTriliumOptionBool("hideArchivedNotes_main"); const separatorProps: Omit = { noteContext, hideArchivedNotes }; @@ -73,7 +73,7 @@ export default function Breadcrumb() {
      ); @@ -291,6 +291,7 @@ function useNotePaths() { return { note, + notePath, notePaths: output, noteContext }; @@ -342,9 +343,6 @@ function buildContextMenu(notePath: string, parentComponent: Component | null) { enabled: isNotRoot && !isHoisted }, { kind: "separator" }, - { title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true }, - { title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: notOptionsOrHelp }, - { kind: "separator" }, { title: t("tree-context-menu.duplicate"), command: "duplicateSubtree", @@ -379,6 +377,12 @@ function buildContextMenu(notePath: string, parentComponent: Component | null) { } } : null), { kind: "separator" }, + { + title: t("tree-context-menu.recent-changes-in-subtree"), + uiIcon: "bx bx-history", + enabled: notOptionsOrHelp, + handler: () => parentComponent?.triggerCommand("showRecentChanges", { ancestorNoteId: noteId }) + }, { title: t("tree-context-menu.search-in-subtree"), command: "searchInSubtree", @@ -397,21 +401,12 @@ function buildContextMenu(notePath: string, parentComponent: Component | null) { } if (!command) return; - switch (command) { - case "copyNotePathToClipboard": - copyTextWithToast(`#${notePath}`); - break; - case "recentChangesInSubtree": - parentComponent?.triggerCommand("showRecentChanges", { ancestorNoteId: noteId }); - break; - default: - parentComponent?.triggerCommand(command, { - noteId, - notePath, - selectedOrActiveBranchIds: [ branchId ], - selectedOrActiveNoteIds: [ noteId ] - }); - } + parentComponent?.triggerCommand(command, { + noteId, + notePath, + selectedOrActiveBranchIds: [ branchId ], + selectedOrActiveNoteIds: [ noteId ] + }); }, }); }; @@ -419,7 +414,7 @@ function buildContextMenu(notePath: string, parentComponent: Component | null) { //#endregion //#region Empty context menu -function buildEmptyAreaContextMenu(parentComponent: Component | null) { +function buildEmptyAreaContextMenu(parentComponent: Component | null, notePath: string | null | undefined) { return (e: MouseEvent) => { const hideArchivedNotes = (options.get("hideArchivedNotes_main") === "true"); @@ -435,7 +430,14 @@ function buildEmptyAreaContextMenu(parentComponent: Component | null) { parentComponent?.triggerEvent("frocaReloaded", {}); }, checked: hideArchivedNotes - } + }, + { kind: "separator" }, + { + title: t("tree-context-menu.copy-note-path-to-clipboard"), + command: "copyNotePathToClipboard", + uiIcon: "bx bx-directions", + handler: () => copyTextWithToast(`#${notePath}`) + }, ], x: e.pageX, y: e.pageY, From 8c008e2e3ac5007985fbcc1b885ecb46a6aeb811 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 17:14:43 +0200 Subject: [PATCH 30/31] chore(breadcrumb): address requested changes --- apps/client/src/services/link.ts | 2 +- apps/client/src/widgets/layout/Breadcrumb.tsx | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/client/src/services/link.ts b/apps/client/src/services/link.ts index 03595920b..b74dd5f7b 100644 --- a/apps/client/src/services/link.ts +++ b/apps/client/src/services/link.ts @@ -335,7 +335,7 @@ export function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDo window.open(hrefLink, "_blank"); } else { // Enable protocols supported by CKEditor 5 to be clickable. - if (ALLOWED_PROTOCOLS.some((protocol) => hrefLink.toLowerCase().startsWith(`${protocol }:`))) { + if (ALLOWED_PROTOCOLS.some((protocol) => hrefLink.toLowerCase().startsWith(`${protocol}:`))) { if ( utils.isElectron()) { const electron = utils.dynamicRequire("electron"); electron.shell.openExternal(hrefLink); diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index d3d790080..350b52999 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -313,11 +313,11 @@ function buildContextMenu(notePath: string, parentComponent: Component | null) { const note = await branch?.getNote(); if (!note) return; - const notSearch = note?.type !== "search"; - const notOptionsOrHelp = !note?.noteId.startsWith("_options") && !note?.noteId.startsWith("_help"); + const notSearch = note.type !== "search"; + const notOptionsOrHelp = !note.noteId.startsWith("_options") && !note.noteId.startsWith("_help"); const isArchived = note.isArchived; - const isNotRoot = note?.noteId !== "root"; - const isHoisted = note?.noteId === appContext.tabManager.getActiveContext()?.hoistedNoteId; + const isNotRoot = note.noteId !== "root"; + const isHoisted = note.noteId === appContext.tabManager.getActiveContext()?.hoistedNoteId; const parentNote = isNotRoot && branch ? await froca.getNote(branch.parentNoteId) : null; const parentNotSearch = !parentNote || parentNote.type !== "search"; @@ -369,7 +369,7 @@ function buildContextMenu(notePath: string, parentComponent: Component | null) { enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp, handler: () => branches.deleteNotes([ branchId ]) }, - { kind: "separator"}, + { kind: "separator" }, (notOptionsOrHelp ? { kind: "custom", componentFn: () => { @@ -396,7 +396,7 @@ function buildContextMenu(notePath: string, parentComponent: Component | null) { x: e.pageX, y: e.pageY, selectMenuItemHandler: ({ command }) => { - if (link_context_menu.handleLinkContextMenuItem(command, e, note.noteId)) { + if (link_context_menu.handleLinkContextMenuItem(command, e, notePath)) { return; } From 120b5c678d6901735011c93eadc7bc688253a6cf Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 16 Dec 2025 17:27:29 +0200 Subject: [PATCH 31/31] chore(client): fix typecheck --- apps/client/src/menus/link_context_menu.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/menus/link_context_menu.ts b/apps/client/src/menus/link_context_menu.ts index b9c99c612..607ff6c56 100644 --- a/apps/client/src/menus/link_context_menu.ts +++ b/apps/client/src/menus/link_context_menu.ts @@ -16,7 +16,7 @@ function openContextMenu(notePath: string, e: ContextMenuEvent, viewScope: ViewS }); } -function getItems(e: ContextMenuEvent | LeafletMouseEvent): MenuItem<"openNoteInNewTab" | "openNoteInNewSplit" | "openNoteInNewWindow" | "openNoteInPopup">[] { +function getItems(e: ContextMenuEvent | LeafletMouseEvent): MenuItem[] { const ntxId = getNtxId(e); const isMobileSplitOpen = isMobile() && appContext.tabManager.getNoteContextById(ntxId).getMainContext().getSubContexts().length > 1;