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; } diff --git a/apps/client/src/menus/link_context_menu.ts b/apps/client/src/menus/link_context_menu.ts index 0799a58ab..607ff6c56 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/services/link.ts b/apps/client/src/services/link.ts index 4ff3a39cf..b74dd5f7b 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/translations/en/translation.json b/apps/client/src/translations/en/translation.json index f6f167996..10a3cb8cb 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2148,7 +2148,9 @@ "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", + "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 436b9cb0f..947f8266e 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.css +++ b/apps/client/src/widgets/layout/Breadcrumb.css @@ -15,6 +15,18 @@ color: var(--main-text-color); } + a.tn-link { + color: var(--custom-color, inherit); + + &:hover { + color: var(--custom-color, inherit); + } + } + + .archived { + opacity: 0.6; + } + > span, > span > span { display: flex; @@ -26,7 +38,6 @@ } a { - color: inherit; text-decoration: none; min-width: 0; max-width: 150px; @@ -58,12 +69,12 @@ overflow: hidden; display: block; max-width: 300px; + color: var(--custom-color, inherit) !important; } a.breadcrumb-last-item, a.breadcrumb-last-item:visited { text-decoration: none; - color: currentColor; font-weight: 600; } @@ -71,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 a814fb13a..350b52999 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -1,61 +1,80 @@ import "./Breadcrumb.css"; -import { useRef, useState } from "preact/hooks"; +import clsx from "clsx"; +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"; +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 { 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 options from "../../services/options"; +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 { useChildNotes, useNote, useNoteIcon, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useStaticTooltip } from "../react/hooks"; +import { FormDropdownDivider, FormListItem } from "../react/FormList"; +import { useActiveNoteContext, useChildNotes, useNote, useNoteColorClass, useNoteIcon, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useStaticTooltip, useTriliumOptionBool } 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; 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, notePath, notePaths, noteContext } = useNotePaths(); + const parentComponent = useContext(ParentComponent); + const [ hideArchivedNotes ] = useTriliumOptionBool("hideArchivedNotes_main"); + const separatorProps: Omit = { noteContext, hideArchivedNotes }; 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()) && + } )) )} + +
); } @@ -104,7 +123,7 @@ function BreadcrumbHoistedNoteRoot({ noteId }: { noteId: string }) { "color": getReadableTextColor(workspaceColor) } : undefined} /> - (null); const noteId = notePath.split("/").at(-1); 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") @@ -129,31 +150,43 @@ function BreadcrumbLastItem({ notePath }: { notePath: string }) {
{ const activeNtxId = appContext.tabManager.activeNtxId; 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 }) { +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 ; + return ; } -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 ( } @@ -162,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); @@ -176,11 +209,13 @@ 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
  • noteContext?.setNote(childNotePath)} > {childNotePath !== activeNotePath @@ -189,11 +224,20 @@ function BreadcrumbSeparatorDropdownContent({ notePath, noteContext, activeNoteP
  • ; })} + + + note_create.createNote(notePath, { activate: true })} + >{t("breadcrumb.create_new_note")}
); } -function BreadcrumbCollapsed({ items, noteContext }: { items: string[], noteContext: NoteContext | undefined }) { +function BreadcrumbCollapsed({ items, noteContext }: { + items: string[], + noteContext: NoteContext | undefined, +}) { return ( } @@ -223,16 +267,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}`); @@ -241,9 +285,164 @@ 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, + notePath, + notePaths: output, + noteContext + }; } + +//#region Note Context menu +function buildContextMenu(notePath: string, parentComponent: Component | null) { + return async (e: MouseEvent) => { + e.preventDefault(); + + const { noteId, parentNoteId } = tree.getNoteIdAndParentIdFromUrl(notePath); + 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.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.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", + 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, notePath)) { + return; + } + + if (!command) return; + parentComponent?.triggerCommand(command, { + noteId, + notePath, + selectedOrActiveBranchIds: [ branchId ], + selectedOrActiveNoteIds: [ noteId ] + }); + }, + }); + }; +} +//#endregion + +//#region Empty context menu +function buildEmptyAreaContextMenu(parentComponent: Component | null, notePath: string | null | undefined) { + return (e: MouseEvent) => { + const hideArchivedNotes = (options.get("hideArchivedNotes_main") === "true"); + + e.preventDefault(); + contextMenu.show({ + items: [ + { + title: t("breadcrumb.empty_hide_archived_notes"), + handler: async () => { + await options.save("hideArchivedNotes_main", !hideArchivedNotes ? "true" : "false"); + + // Note tree doesn't update by itself. + 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, + selectMenuItemHandler: () => {} + }); + }; +} +//#endregion 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/NoteLink.tsx b/apps/client/src/widgets/react/NoteLink.tsx index 7c3578db7..a415c8b86 100644 --- a/apps/client/src/widgets/react/NoteLink.tsx +++ b/apps/client/src/widgets/react/NoteLink.tsx @@ -1,6 +1,10 @@ +import clsx from "clsx"; +import { HTMLAttributes } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; -import link, { ViewScope } from "../../services/link"; -import { useImperativeSearchHighlighlighting, useTriliumEvent } from "./hooks"; + +import link, { calculateHash, ViewScope } from "../../services/link"; +import { useImperativeSearchHighlighlighting, useNote, useNoteColorClass, useNoteIcon, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumEvent } from "./hooks"; +import Icon from "./Icon"; interface NoteLinkOpts { className?: string; @@ -16,9 +20,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 +40,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]); @@ -74,3 +86,41 @@ export default function NoteLink({ className, containerClassName, notePath, show return ; } + +interface NewNoteLinkProps extends Pick, "onContextMenu"> { + className?: string; + 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); + const colorClass = useNoteColorClass(note); + const [ archived ] = useNoteLabelBoolean(note, "archived"); + + return ( + + + {icon && } + + + {title} + + + + ); +} diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index d01fb8156..f23b85d0c 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, @@ -1049,3 +1061,12 @@ export function useNoteIcon(note: FNote | null | undefined) { return icon; } + +export function useNoteColorClass(note: FNote | null | undefined) { + const [ colorClass, setColorClass ] = useState(note?.getColorClass()); + const [ color ] = useNoteLabel(note, "color"); + useEffect(() => { + setColorClass(note?.getColorClass()); + }, [ color, note ]); + return colorClass; +} 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" }; });