feat(launch_bar): add a context menu to every option to easily remove them

This commit is contained in:
Elian Doran
2026-04-16 22:52:02 +03:00
parent 889e44363a
commit 999bfbc118
14 changed files with 237 additions and 79 deletions

View File

@@ -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<ToggleInParentResponse>(
`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<T extends string> {
/** 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<T>[];
/** 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<T extends string>(
launcherNote: FNote | null | undefined,
e: ContextMenuEvent,
options: ShowLauncherContextMenuOptions<T> = {}
) {
e.preventDefault();
const items = [...(options.extraItems ?? [])] as MenuItem<string>[];
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<string>({
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);
}
});
}

View File

@@ -1928,6 +1928,9 @@
"move-to-available-launchers": "Move to available launchers",
"duplicate-launcher": "Duplicate launcher <kbd data-command=\"duplicateSubtree\">"
},
"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.",

View File

@@ -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<CSSProperties>(() => ({
display: "flex",
@@ -22,20 +22,27 @@ export default function BookmarkButtons() {
contain: "none"
}), [ isHorizontalLayout ]);
const childNotes = useChildNotes(PARENT_NOTE_ID);
const bookmarks = childNotes?.map(childNote => <SingleBookmark key={childNote.noteId} note={childNote} />);
const showContextMenu = launcherContextMenuHandler(launcherNote);
return (
<ResponsiveContainer
desktop={
<div style={style}>
{childNotes?.map(childNote => <SingleBookmark key={childNote.noteId} note={childNote} />)}
<div
style={style}
// Only trigger on empty container area; individual bookmark buttons handle their own context menu.
onContextMenu={(e) => e.target === e.currentTarget && showContextMenu?.(e)}
>
{bookmarks}
</div>
}
mobile={
<LaunchBarDropdownButton
launcherNote={launcherNote}
icon="bx bx-bookmark"
title={t("bookmark_buttons.bookmarks")}
>
{childNotes?.map(childNote => <SingleBookmark key={childNote.noteId} note={childNote} />)}
{bookmarks}
</LaunchBarDropdownButton>
}
/>
@@ -90,6 +97,7 @@ function BookmarkFolder({ note }: { note: FNote }) {
return (
<LaunchBarDropdownButton
launcherNote={note}
icon={icon}
title={title}
>

View File

@@ -58,6 +58,7 @@ export default function CalendarWidget({ launcherNote }: LauncherNoteProps) {
return (
<LaunchBarDropdownButton
launcherNote={launcherNote}
icon={icon} title={title}
onShown={async () => {
const dateNote = appContext.tabManager.getActiveContextNote()?.getOwnedLabelValue("dateNote");

View File

@@ -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<CommandNames>(launcherNote, evt, {
extraItems: linkItems,
onCommand: (command) => {
if (command && targetNoteId) {
link_context_menu.handleLinkContextMenuItem(command, evt, targetNoteId, {}, hoistedNoteId);
}
}
});
}}
/>
);

View File

@@ -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<string>(launcherNote, e, {
extraItems: items,
onCommand: (cmd) => {
if (cmd && webContents) {
webContents.navigationHistory.goToIndex(parseInt(cmd, 10));
}
}
});
}}
/>
);
}
async function getHistoryItems(webContents: WebContents): Promise<MenuCommandItem<string>[]> {
if (webContents.navigationHistory.length() < 2) return [];
let items: MenuCommandItem<string>[] = [];
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<string>[] = [];
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,

View File

@@ -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 <SpacerWidget baseSize={baseSize} growthFactor={growthFactor} />;
return <SpacerWidget launcherNote={note} baseSize={baseSize} growthFactor={growthFactor} />;
case "bookmarks":
return <BookmarkButtons />;
return <BookmarkButtons launcherNote={note} />;
case "protectedSession":
return <ProtectedSessionStatusWidget />;
return <ProtectedSessionStatusWidget launcherNote={note} />;
case "syncStatus":
return <SyncStatus />;
return <SyncStatus launcherNote={note} />;
case "backInHistoryButton":
return <HistoryNavigationButton launcherNote={note} command="backInNoteHistory" />;
case "forwardInHistoryButton":
@@ -97,11 +97,11 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) {
case "todayInJournal":
return <TodayLauncher launcherNote={note} />;
case "quickSearch":
return <QuickSearchLauncherWidget />;
return <QuickSearchLauncherWidget launcherNote={note} />;
case "mobileTabSwitcher":
return <TabSwitcher />;
return <TabSwitcher launcherNote={note} />;
case "sidebarChat":
return isExperimentalFeatureEnabled("llm") ? <SidebarChatButton /> : undefined;
return isExperimentalFeatureEnabled("llm") ? <SidebarChatButton launcherNote={note} /> : undefined;
default:
console.warn(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
}

View File

@@ -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 && (
<LaunchBarActionButton
launcherNote={launcherNote}
icon={icon}
text={title}
triggerCommand={command as CommandNames}
@@ -74,6 +75,7 @@ export function ScriptLauncher({ launcherNote }: LauncherNoteProps) {
return (
<LaunchBarActionButton
launcherNote={launcherNote}
icon={icon}
text={title}
onClick={launch}
@@ -93,7 +95,7 @@ export function TodayLauncher({ launcherNote }: LauncherNoteProps) {
);
}
export function QuickSearchLauncherWidget() {
export function QuickSearchLauncherWidget({ launcherNote }: LauncherNoteProps) {
const { isHorizontalLayout } = useContext(LaunchBarContext);
const widget = useMemo(() => new QuickSearchWidget(), []);
const parentComponent = useContext(ParentComponent) as BasicWidget | null;
@@ -101,7 +103,7 @@ export function QuickSearchLauncherWidget() {
parentComponent?.contentSized();
return (
<div>
<div onContextMenu={launcherContextMenuHandler(launcherNote)}>
{isEnabled && <LegacyWidgetRenderer widget={widget} />}
</div>
);
@@ -136,7 +138,7 @@ export function CustomWidget({ launcherNote }: LauncherNoteProps) {
}, [ widgetNote ]);
return (
<div>
<div onContextMenu={launcherContextMenuHandler(launcherNote)}>
{widget && (
("type" in widget && widget.type === "preact-launcher-widget")
? <ReactWidgetRenderer widget={widget as LauncherWidgetDefinitionWithType} />

View File

@@ -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 ? (
<LaunchBarActionButton
launcherNote={launcherNote}
icon="bx bx-check-shield"
text={t("protected_session_status.active")}
triggerCommand="leaveProtectedSession"
/>
) : (
<LaunchBarActionButton
launcherNote={launcherNote}
icon="bx bx-shield-quarter"
text={t("protected_session_status.inactive")}
triggerCommand="enterProtectedSession"

View File

@@ -2,13 +2,13 @@ import { useCallback } from "preact/hooks";
import appContext from "../../components/app_context";
import { t } from "../../services/i18n";
import { LaunchBarActionButton } from "./launch_bar_widgets";
import { LaunchBarActionButton, LauncherNoteProps } from "./launch_bar_widgets";
/**
* Launcher button to open the sidebar (which contains the chat).
* The chat widget is always visible in the sidebar for non-chat notes.
*/
export default function SidebarChatButton() {
export default function SidebarChatButton({ launcherNote }: LauncherNoteProps) {
const handleClick = useCallback(() => {
// Open right pane if hidden, or toggle it if visible
appContext.triggerEvent("toggleRightPane", {});
@@ -16,6 +16,7 @@ export default function SidebarChatButton() {
return (
<LaunchBarActionButton
launcherNote={launcherNote}
icon="bx bx-message-square-dots"
text={t("sidebar_chat.launcher_title")}
onClick={handleClick}

View File

@@ -1,14 +1,16 @@
import appContext, { CommandNames } from "../../components/app_context";
import contextMenu from "../../menus/context_menu";
import FNote from "../../entities/fnote";
import { showLauncherContextMenu } from "../../menus/launcher_button_context_menu";
import { t } from "../../services/i18n";
import { isMobile } from "../../services/utils";
interface SpacerWidgetProps {
launcherNote?: FNote;
baseSize?: number;
growthFactor?: number;
}
export default function SpacerWidget({ baseSize, growthFactor }: SpacerWidgetProps) {
export default function SpacerWidget({ launcherNote, baseSize, growthFactor }: SpacerWidgetProps) {
return (
<div
className="spacer"
@@ -17,19 +19,16 @@ export default function SpacerWidget({ baseSize, growthFactor }: SpacerWidgetPro
flexGrow: growthFactor ?? 1000,
flexShrink: 1000
}}
onContextMenu={(e) => {
e.preventDefault();
contextMenu.show<CommandNames>({
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<CommandNames>(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}
/>
)
}

View File

@@ -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<SyncState, StateMapping> = {
}
};
export default function SyncStatus() {
export default function SyncStatus({ launcherNote }: LauncherNoteProps) {
const syncState = useSyncStatus();
const { title, icon, hasChanges } = STATE_MAPPINGS[syncState];
const spanRef = useRef<HTMLSpanElement>(null);
@@ -60,7 +61,10 @@ export default function SyncStatus() {
});
return (syncServerHost &&
<div class="sync-status-widget launcher-button">
<div
class="sync-status-widget launcher-button"
onContextMenu={launcherContextMenuHandler(launcherNote)}
>
<div class="sync-status">
<span
key={syncState} // Force re-render when state changes to update tooltip content.

View File

@@ -3,6 +3,7 @@ import { createContext } from "preact";
import { useContext } from "preact/hooks";
import FNote from "../../entities/fnote";
import { showLauncherContextMenu } from "../../menus/launcher_button_context_menu";
import utils from "../../services/utils";
import ActionButton, { ActionButtonProps } from "../react/ActionButton";
import Dropdown, { DropdownProps } from "../react/Dropdown";
@@ -22,7 +23,13 @@ export interface LauncherNoteProps {
launcherNote: FNote;
}
export function LaunchBarActionButton({ className, ...props }: Omit<ActionButtonProps, "noIconActionClass" | "titlePosition">) {
/** 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<ActionButtonProps, "noIconActionClass" | "titlePosition"> & { launcherNote?: FNote }) {
const { isHorizontalLayout } = useContext(LaunchBarContext);
return (
@@ -30,15 +37,20 @@ export function LaunchBarActionButton({ className, ...props }: Omit<ActionButton
className={clsx("button-widget launcher-button", className)}
noIconActionClass
titlePosition={getTitlePosition(isHorizontalLayout)}
onContextMenu={onContextMenu ?? launcherContextMenuHandler(launcherNote)}
{...props}
/>
);
}
export function LaunchBarDropdownButton({ children, icon, dropdownOptions, ...props }: Pick<DropdownProps, "title" | "children" | "onShown" | "dropdownOptions" | "dropdownRef"> & { icon: string }) {
export function LaunchBarDropdownButton({ children, icon, dropdownOptions, launcherNote, buttonProps, ...props }: Pick<DropdownProps, "title" | "children" | "onShown" | "dropdownOptions" | "dropdownRef" | "buttonProps"> & { icon: string, launcherNote?: FNote }) {
const { isHorizontalLayout } = useContext(LaunchBarContext);
const titlePosition = getTitlePosition(isHorizontalLayout);
const resolvedButtonProps = launcherNote && !buttonProps?.onContextMenu
? { ...buttonProps, onContextMenu: launcherContextMenuHandler(launcherNote) }
: buttonProps;
return (
<Dropdown
className="right-dropdown-widget"
@@ -54,6 +66,7 @@ export function LaunchBarDropdownButton({ children, icon, dropdownOptions, ...pr
}
}}
mobileBackdrop
buttonProps={resolvedButtonProps}
{...props}
>{children}</Dropdown>
);

View File

@@ -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<Exclude<ViewMode, "default">, string> = {
ocr: "bx bx-text"
};
export default function TabSwitcher() {
export default function TabSwitcher({ launcherNote }: LauncherNoteProps) {
const [ shown, setShown ] = useState(false);
const mainNoteContexts = useMainNoteContexts();
return (
<>
<LaunchBarActionButton
launcherNote={launcherNote}
className="mobile-tab-switcher"
icon="bx bx-rectangle"
text="Tabs"