diff --git a/apps/client/src/menus/launcher_button_context_menu.ts b/apps/client/src/menus/launcher_button_context_menu.ts new file mode 100644 index 0000000000..1272deb859 --- /dev/null +++ b/apps/client/src/menus/launcher_button_context_menu.ts @@ -0,0 +1,101 @@ +import type { ToggleInParentResponse } from "@triliumnext/commons"; + +import type FNote from "../entities/fnote.js"; +import branchService from "../services/branches.js"; +import { t } from "../services/i18n.js"; +import server from "../services/server.js"; +import toast from "../services/toast.js"; +import contextMenu, { type ContextMenuEvent, type MenuItem } from "./context_menu.js"; + +const VISIBLE_LAUNCHER_PARENTS = ["_lbVisibleLaunchers", "_lbMobileVisibleLaunchers"]; + +function getVisibleLauncherBranch(launcherNote: FNote) { + return launcherNote.getParentBranches().find((b) => VISIBLE_LAUNCHER_PARENTS.includes(b.parentNoteId)); +} + +function getBookmarkBranch(launcherNote: FNote) { + return launcherNote.getParentBranches().find((b) => b.parentNoteId === "_lbBookmarks"); +} + +async function removeFromLaunchBar(launcherNote: FNote) { + const bookmarkBranch = getBookmarkBranch(launcherNote); + if (bookmarkBranch) { + // Individual bookmarks are represented via a branch under `_lbBookmarks`; removing them + // from the launch bar is the same as unbookmarking the note. + const resp = await server.put( + `notes/${launcherNote.noteId}/toggle-in-parent/_lbBookmarks/false` + ); + if (!resp.success && resp.message) { + toast.showError(resp.message); + } + return; + } + + const launcherBranch = getVisibleLauncherBranch(launcherNote); + if (!launcherBranch) return; + + const isMobileLauncher = launcherBranch.parentNoteId === "_lbMobileVisibleLaunchers"; + // Branch IDs in the hidden subtree follow the `${parentNoteId}_${noteId}` convention, + // so the branch linking `_lb(Mobile)?Root` to the "available" launchers root is predictable. + const targetBranchId = isMobileLauncher + ? "_lbMobileRoot__lbMobileAvailableLaunchers" + : "_lbRoot__lbAvailableLaunchers"; + await branchService.moveToParentNote([launcherBranch.branchId], targetBranchId); +} + +export function canRemoveFromLaunchBar(launcherNote: FNote | null | undefined) { + if (!launcherNote) return false; + return !!(getVisibleLauncherBranch(launcherNote) || getBookmarkBranch(launcherNote)); +} + +export interface ShowLauncherContextMenuOptions { + /** Menu items specific to this launcher (e.g. "Open in new tab" for note-based launchers). They appear above the "Remove from launch bar" item. */ + extraItems?: MenuItem[]; + /** Handler for the {@link extraItems}. The "Remove from launch bar" item is handled internally and will not be forwarded. */ + onCommand?: (command: T | undefined) => void; +} + +const REMOVE_COMMAND = "__removeFromLaunchBar__"; + +/** + * Displays the launch bar icon context menu. When the launcher can be removed (i.e. it is a direct + * child of the visible launchers root or of `_lbBookmarks`), a "Remove from launch bar" entry is + * appended. Extra items can be supplied to preserve launcher-specific actions (e.g. "Open in new tab"). + */ +export async function showLauncherContextMenu( + launcherNote: FNote | null | undefined, + e: ContextMenuEvent, + options: ShowLauncherContextMenuOptions = {} +) { + e.preventDefault(); + + const items = [...(options.extraItems ?? [])] as MenuItem[]; + + if (canRemoveFromLaunchBar(launcherNote)) { + if (items.length > 0) { + items.push({ kind: "separator" }); + } + items.push({ + title: t("launcher_button_context_menu.remove_from_launch_bar"), + command: REMOVE_COMMAND, + uiIcon: "bx bx-x-circle" + }); + } + + if (items.length === 0) return; + + contextMenu.show({ + x: e.pageX ?? 0, + y: e.pageY ?? 0, + items, + selectMenuItemHandler: ({ command }) => { + if (command === REMOVE_COMMAND) { + if (launcherNote) { + void removeFromLaunchBar(launcherNote); + } + return; + } + options.onCommand?.(command as T | undefined); + } + }); +} diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 190edb998d..2efdf6b542 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1928,6 +1928,9 @@ "move-to-available-launchers": "Move to available launchers", "duplicate-launcher": "Duplicate launcher " }, + "launcher_button_context_menu": { + "remove_from_launch_bar": "Remove from launch bar" + }, "highlighting": { "title": "Code Blocks", "description": "Controls the syntax highlighting for code blocks inside text notes, code notes will not be affected.", diff --git a/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx b/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx index 81f2c6e878..d4e761fd9d 100644 --- a/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx +++ b/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx @@ -10,11 +10,11 @@ import { useChildNotes, useNote, useNoteIcon, useNoteLabelBoolean } from "../rea import NoteLink from "../react/NoteLink"; import ResponsiveContainer from "../react/ResponsiveContainer"; import { CustomNoteLauncher, launchCustomNoteLauncher } from "./GenericButtons"; -import { LaunchBarContext, LaunchBarDropdownButton, useLauncherIconAndTitle } from "./launch_bar_widgets"; +import { LaunchBarContext, LaunchBarDropdownButton, launcherContextMenuHandler, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets"; const PARENT_NOTE_ID = "_lbBookmarks"; -export default function BookmarkButtons() { +export default function BookmarkButtons({ launcherNote }: LauncherNoteProps) { const { isHorizontalLayout } = useContext(LaunchBarContext); const style = useMemo(() => ({ display: "flex", @@ -22,20 +22,27 @@ export default function BookmarkButtons() { contain: "none" }), [ isHorizontalLayout ]); const childNotes = useChildNotes(PARENT_NOTE_ID); + const bookmarks = childNotes?.map(childNote => ); + const showContextMenu = launcherContextMenuHandler(launcherNote); return ( - {childNotes?.map(childNote => )} +
e.target === e.currentTarget && showContextMenu?.(e)} + > + {bookmarks}
} mobile={ - {childNotes?.map(childNote => )} + {bookmarks} } /> @@ -90,6 +97,7 @@ function BookmarkFolder({ note }: { note: FNote }) { return ( diff --git a/apps/client/src/widgets/launch_bar/CalendarWidget.tsx b/apps/client/src/widgets/launch_bar/CalendarWidget.tsx index 1972672232..21e4689cff 100644 --- a/apps/client/src/widgets/launch_bar/CalendarWidget.tsx +++ b/apps/client/src/widgets/launch_bar/CalendarWidget.tsx @@ -58,6 +58,7 @@ export default function CalendarWidget({ launcherNote }: LauncherNoteProps) { return ( { const dateNote = appContext.tabManager.getActiveContextNote()?.getOwnedLabelValue("dateNote"); diff --git a/apps/client/src/widgets/launch_bar/GenericButtons.tsx b/apps/client/src/widgets/launch_bar/GenericButtons.tsx index 59c158dec1..ba29b303b5 100644 --- a/apps/client/src/widgets/launch_bar/GenericButtons.tsx +++ b/apps/client/src/widgets/launch_bar/GenericButtons.tsx @@ -1,7 +1,8 @@ import { useCallback } from "preact/hooks"; -import appContext from "../../components/app_context"; +import appContext, { CommandNames } from "../../components/app_context"; import FNote from "../../entities/fnote"; +import { showLauncherContextMenu } from "../../menus/launcher_button_context_menu"; import link_context_menu from "../../menus/link_context_menu"; import { isCtrlKey } from "../../services/utils"; import { useGlobalShortcut, useNoteLabel } from "../react/hooks"; @@ -13,7 +14,7 @@ export function CustomNoteLauncher(props: { getHoistedNoteId?: (launcherNote: FNote) => string | null; keyboardShortcut?: string; }) { - const { launcherNote, getTargetNoteId } = props; + const { launcherNote, getTargetNoteId, getHoistedNoteId } = props; const { icon, title } = useLauncherIconAndTitle(launcherNote); const launch = useCallback(async (evt: MouseEvent | KeyboardEvent) => { @@ -31,11 +32,17 @@ export function CustomNoteLauncher(props: { onClick={launch} onAuxClick={launch} onContextMenu={async evt => { - evt.preventDefault(); const targetNoteId = await getTargetNoteId(launcherNote); - if (targetNoteId) { - link_context_menu.openContextMenu(targetNoteId, evt); - } + const hoistedNoteId = getHoistedNoteId?.(launcherNote) ?? null; + const linkItems = targetNoteId ? link_context_menu.getItems(evt) : []; + await showLauncherContextMenu(launcherNote, evt, { + extraItems: linkItems, + onCommand: (command) => { + if (command && targetNoteId) { + link_context_menu.handleLinkContextMenuItem(command, evt, targetNoteId, {}, hoistedNoteId); + } + } + }); }} /> ); diff --git a/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx b/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx index 3e0ce6f967..11ad476312 100644 --- a/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx +++ b/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx @@ -3,6 +3,7 @@ import { useMemo } from "preact/hooks"; import FNote from "../../entities/fnote"; import contextMenu, { MenuCommandItem } from "../../menus/context_menu"; +import { showLauncherContextMenu } from "../../menus/launcher_button_context_menu"; import froca from "../../services/froca"; import link from "../../services/link"; import tree from "../../services/tree"; @@ -25,46 +26,61 @@ export default function HistoryNavigationButton({ launcherNote, command }: Histo icon={icon} text={title} triggerCommand={command} - onContextMenu={webContents ? handleHistoryContextMenu(webContents) : undefined} + onContextMenu={async (e) => { + const items = webContents ? await getHistoryItems(webContents) : []; + showLauncherContextMenu(launcherNote, e, { + extraItems: items, + onCommand: (cmd) => { + if (cmd && webContents) { + webContents.navigationHistory.goToIndex(parseInt(cmd, 10)); + } + } + }); + }} /> ); } +async function getHistoryItems(webContents: WebContents): Promise[]> { + if (webContents.navigationHistory.length() < 2) return []; + + let items: MenuCommandItem[] = []; + + const history = webContents.navigationHistory.getAllEntries(); + const activeIndex = webContents.navigationHistory.getActiveIndex(); + + for (const idx in history) { + const { noteId, notePath } = link.parseNavigationStateFromUrl(history[idx].url); + if (!noteId || !notePath) continue; + + const title = await tree.getNotePathTitle(notePath); + const index = parseInt(idx, 10); + const note = froca.getNoteFromCache(noteId); + + items.push({ + title, + command: idx, + checked: index === activeIndex, + enabled: index !== activeIndex, + uiIcon: note?.getIcon() + }); + } + + items.reverse(); + + if (items.length > HISTORY_LIMIT) { + items = items.slice(0, HISTORY_LIMIT); + } + + return items; +} + export function handleHistoryContextMenu(webContents: WebContents) { return async (e: MouseEvent) => { e.preventDefault(); - if (!webContents || webContents.navigationHistory.length() < 2) { - return; - } - - let items: MenuCommandItem[] = []; - - const history = webContents.navigationHistory.getAllEntries(); - const activeIndex = webContents.navigationHistory.getActiveIndex(); - - for (const idx in history) { - const { noteId, notePath } = link.parseNavigationStateFromUrl(history[idx].url); - if (!noteId || !notePath) continue; - - const title = await tree.getNotePathTitle(notePath); - const index = parseInt(idx, 10); - const note = froca.getNoteFromCache(noteId); - - items.push({ - title, - command: idx, - checked: index === activeIndex, - enabled: index !== activeIndex, - uiIcon: note?.getIcon() - }); - } - - items.reverse(); - - if (items.length > HISTORY_LIMIT) { - items = items.slice(0, HISTORY_LIMIT); - } + const items = await getHistoryItems(webContents); + if (items.length === 0) return; contextMenu.show({ x: e.pageX, diff --git a/apps/client/src/widgets/launch_bar/LauncherContainer.tsx b/apps/client/src/widgets/launch_bar/LauncherContainer.tsx index d5d443084b..dd3ae9d6e3 100644 --- a/apps/client/src/widgets/launch_bar/LauncherContainer.tsx +++ b/apps/client/src/widgets/launch_bar/LauncherContainer.tsx @@ -83,13 +83,13 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) { const baseSize = parseInt(note.getLabelValue("baseSize") || "40"); const growthFactor = parseInt(note.getLabelValue("growthFactor") || "100"); - return ; + return ; case "bookmarks": - return ; + return ; case "protectedSession": - return ; + return ; case "syncStatus": - return ; + return ; case "backInHistoryButton": return ; case "forwardInHistoryButton": @@ -97,11 +97,11 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) { case "todayInJournal": return ; case "quickSearch": - return ; + return ; case "mobileTabSwitcher": - return ; + return ; case "sidebarChat": - return isExperimentalFeatureEnabled("llm") ? : undefined; + return isExperimentalFeatureEnabled("llm") ? : undefined; default: console.warn(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`); } diff --git a/apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx b/apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx index 408780224c..89bdb39194 100644 --- a/apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx +++ b/apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx @@ -14,7 +14,7 @@ import QuickSearchWidget from "../quick_search"; import { useGlobalShortcut, useLegacyWidget, useNoteLabel, useNoteRelationTarget } from "../react/hooks"; import { ParentComponent } from "../react/react_utils"; import { CustomNoteLauncher } from "./GenericButtons"; -import { LaunchBarActionButton, LaunchBarContext, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets"; +import { LaunchBarActionButton, LaunchBarContext, launcherContextMenuHandler, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets"; export function CommandButton({ launcherNote }: LauncherNoteProps) { const { icon, title } = useLauncherIconAndTitle(launcherNote); @@ -22,6 +22,7 @@ export function CommandButton({ launcherNote }: LauncherNoteProps) { return command && ( new QuickSearchWidget(), []); const parentComponent = useContext(ParentComponent) as BasicWidget | null; @@ -101,7 +103,7 @@ export function QuickSearchLauncherWidget() { parentComponent?.contentSized(); return ( -
+
{isEnabled && }
); @@ -136,7 +138,7 @@ export function CustomWidget({ launcherNote }: LauncherNoteProps) { }, [ widgetNote ]); return ( -
+
{widget && ( ("type" in widget && widget.type === "preact-launcher-widget") ? diff --git a/apps/client/src/widgets/launch_bar/ProtectedSessionStatusWidget.tsx b/apps/client/src/widgets/launch_bar/ProtectedSessionStatusWidget.tsx index 539643d4f9..8e9bcf3bf9 100644 --- a/apps/client/src/widgets/launch_bar/ProtectedSessionStatusWidget.tsx +++ b/apps/client/src/widgets/launch_bar/ProtectedSessionStatusWidget.tsx @@ -1,21 +1,23 @@ import { useState } from "preact/hooks"; import protected_session_holder from "../../services/protected_session_holder"; -import { LaunchBarActionButton } from "./launch_bar_widgets"; +import { LaunchBarActionButton, LauncherNoteProps } from "./launch_bar_widgets"; import { useTriliumEvent } from "../react/hooks"; import { t } from "../../services/i18n"; -export default function ProtectedSessionStatusWidget() { +export default function ProtectedSessionStatusWidget({ launcherNote }: LauncherNoteProps) { const protectedSessionAvailable = useProtectedSessionAvailable(); return ( protectedSessionAvailable ? ( ) : ( { // Open right pane if hidden, or toggle it if visible appContext.triggerEvent("toggleRightPane", {}); @@ -16,6 +16,7 @@ export default function SidebarChatButton() { return ( { - e.preventDefault(); - contextMenu.show({ - x: e.pageX, - y: e.pageY, - items: [{ title: t("spacer.configure_launchbar"), command: "showLaunchBarSubtree", uiIcon: "bx " + (isMobile() ? "bx-mobile" : "bx-sidebar") }], - selectMenuItemHandler: ({ command }) => { - if (command) { - appContext.triggerCommand(command); - } - } - }); - }} + onContextMenu={launcherNote ? (e) => showLauncherContextMenu(launcherNote, e, { + extraItems: [{ + title: t("spacer.configure_launchbar"), + command: "showLaunchBarSubtree", + uiIcon: "bx " + (isMobile() ? "bx-mobile" : "bx-sidebar") + }], + onCommand: (command) => { + if (command) appContext.triggerCommand(command); + } + }) : undefined} /> ) } diff --git a/apps/client/src/widgets/launch_bar/SyncStatus.tsx b/apps/client/src/widgets/launch_bar/SyncStatus.tsx index 651b89c075..27678df17b 100644 --- a/apps/client/src/widgets/launch_bar/SyncStatus.tsx +++ b/apps/client/src/widgets/launch_bar/SyncStatus.tsx @@ -9,6 +9,7 @@ import sync from "../../services/sync"; import { escapeQuotes } from "../../services/utils"; import ws, { subscribeToMessages, unsubscribeToMessage } from "../../services/ws"; import { useStaticTooltip, useTriliumOption } from "../react/hooks"; +import { launcherContextMenuHandler, LauncherNoteProps } from "./launch_bar_widgets"; type SyncState = "unknown" | "in-progress" | "connected-with-changes" | "connected-no-changes" @@ -49,7 +50,7 @@ const STATE_MAPPINGS: Record = { } }; -export default function SyncStatus() { +export default function SyncStatus({ launcherNote }: LauncherNoteProps) { const syncState = useSyncStatus(); const { title, icon, hasChanges } = STATE_MAPPINGS[syncState]; const spanRef = useRef(null); @@ -60,7 +61,10 @@ export default function SyncStatus() { }); return (syncServerHost && -
+
) { +/** Builds the default right-click handler that shows the launch-bar icon context menu (with the "Remove from launch bar" entry). Used by widgets that render a raw element rather than going through {@link LaunchBarActionButton} / {@link LaunchBarDropdownButton}. */ +export function launcherContextMenuHandler(launcherNote: FNote | null | undefined) { + if (!launcherNote) return undefined; + return (e: MouseEvent) => showLauncherContextMenu(launcherNote, e); +} + +export function LaunchBarActionButton({ className, launcherNote, onContextMenu, ...props }: Omit & { launcherNote?: FNote }) { const { isHorizontalLayout } = useContext(LaunchBarContext); return ( @@ -30,15 +37,20 @@ export function LaunchBarActionButton({ className, ...props }: Omit ); } -export function LaunchBarDropdownButton({ children, icon, dropdownOptions, ...props }: Pick & { icon: string }) { +export function LaunchBarDropdownButton({ children, icon, dropdownOptions, launcherNote, buttonProps, ...props }: Pick & { icon: string, launcherNote?: FNote }) { const { isHorizontalLayout } = useContext(LaunchBarContext); const titlePosition = getTitlePosition(isHorizontalLayout); + const resolvedButtonProps = launcherNote && !buttonProps?.onContextMenu + ? { ...buttonProps, onContextMenu: launcherContextMenuHandler(launcherNote) } + : buttonProps; + return ( {children} ); diff --git a/apps/client/src/widgets/mobile_widgets/TabSwitcher.tsx b/apps/client/src/widgets/mobile_widgets/TabSwitcher.tsx index 32f21b94ab..a1aa0f68e4 100644 --- a/apps/client/src/widgets/mobile_widgets/TabSwitcher.tsx +++ b/apps/client/src/widgets/mobile_widgets/TabSwitcher.tsx @@ -14,7 +14,7 @@ import froca from "../../services/froca"; import { t } from "../../services/i18n"; import type { ViewMode, ViewScope } from "../../services/link"; import { NoteContent } from "../collections/legacy/ListOrGridView"; -import { LaunchBarActionButton } from "../launch_bar/launch_bar_widgets"; +import { LaunchBarActionButton, LauncherNoteProps } from "../launch_bar/launch_bar_widgets"; import { ICON_MAPPINGS } from "../note_bars/CollectionProperties"; import ActionButton from "../react/ActionButton"; import { useActiveNoteContext, useNoteIcon, useTriliumEvents } from "../react/hooks"; @@ -30,13 +30,14 @@ const VIEW_MODE_ICON_MAPPINGS: Record, string> = { ocr: "bx bx-text" }; -export default function TabSwitcher() { +export default function TabSwitcher({ launcherNote }: LauncherNoteProps) { const [ shown, setShown ] = useState(false); const mainNoteContexts = useMainNoteContexts(); return ( <>