From 829f3827267cb01e1bc819d79c0ad3ac7e182a20 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 29 Aug 2025 00:47:47 +0300 Subject: [PATCH] feat(react/widgets): global menu with zoom controls --- apps/client/src/components/app_context.ts | 1 + apps/client/src/layouts/desktop_layout.tsx | 10 +- apps/client/src/services/keyboard_actions.ts | 4 + .../src/widgets/buttons/global_menu.css | 81 ++++ .../client/src/widgets/buttons/global_menu.ts | 436 ------------------ .../src/widgets/buttons/global_menu.tsx | 133 ++++++ apps/client/src/widgets/react/Dropdown.tsx | 9 +- apps/client/src/widgets/react/FormList.tsx | 6 +- apps/client/src/widgets/react/hooks.tsx | 18 +- apps/client/src/widgets/ribbon/Ribbon.tsx | 13 +- 10 files changed, 255 insertions(+), 456 deletions(-) create mode 100644 apps/client/src/widgets/buttons/global_menu.css delete mode 100644 apps/client/src/widgets/buttons/global_menu.ts create mode 100644 apps/client/src/widgets/buttons/global_menu.tsx diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index ca4f9745f..2db2363a3 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -134,6 +134,7 @@ export type CommandMappings = { showLeftPane: CommandData; showAttachments: CommandData; showSearchHistory: CommandData; + showShareSubtree: CommandData; hoistNote: CommandData & { noteId: string }; leaveProtectedSession: CommandData; enterProtectedSession: CommandData; diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 79e8d2da4..1ad839b1a 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -1,5 +1,4 @@ import FlexContainer from "../widgets/containers/flex_container.js"; -import GlobalMenuWidget from "../widgets/buttons/global_menu.js"; import TabRowWidget from "../widgets/tab_row.js"; import TitleBarButtonsWidget from "../widgets/title_bar_buttons.js"; import LeftPaneContainer from "../widgets/containers/left_pane_container.js"; @@ -42,6 +41,7 @@ import Ribbon from "../widgets/ribbon/Ribbon.jsx"; import FloatingButtons from "../widgets/FloatingButtons.jsx"; import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx"; import SearchResult from "../widgets/search_result.jsx"; +import GlobalMenu from "../widgets/buttons/global_menu.jsx"; export default class DesktopLayout { @@ -176,12 +176,16 @@ export default class DesktopLayout { let launcherPane; if (isHorizontal) { - launcherPane = new FlexContainer("row").css("height", "53px").class("horizontal").child(new LauncherContainer(true)).child(new GlobalMenuWidget(true)); + launcherPane = new FlexContainer("row") + .css("height", "53px") + .class("horizontal") + .child(new LauncherContainer(true)) + .child(); } else { launcherPane = new FlexContainer("column") .css("width", "53px") .class("vertical") - .child(new GlobalMenuWidget(false)) + .child() .child(new LauncherContainer(false)) .child(new LeftPaneToggleWidget(false)); } diff --git a/apps/client/src/services/keyboard_actions.ts b/apps/client/src/services/keyboard_actions.ts index c72ba29bb..d65b10bb2 100644 --- a/apps/client/src/services/keyboard_actions.ts +++ b/apps/client/src/services/keyboard_actions.ts @@ -62,6 +62,10 @@ async function getAction(actionName: string, silent = false) { return action; } +export function getActionSync(actionName: string) { + return keyboardActionRepo[actionName]; +} + function updateDisplayedShortcuts($container: JQuery) { //@ts-ignore //TODO: each() does not support async callbacks. diff --git a/apps/client/src/widgets/buttons/global_menu.css b/apps/client/src/widgets/buttons/global_menu.css new file mode 100644 index 000000000..23bf1eae8 --- /dev/null +++ b/apps/client/src/widgets/buttons/global_menu.css @@ -0,0 +1,81 @@ +.global-menu { + width: 53px; + height: 53px; + flex-shrink: 0; +} + +.global-menu .dropdown-menu { + min-width: 20em; +} + +.global-menu-button { + width: 100% !important; + height: 100% !important; + position: relative; + padding: 6px; + border: 0; +} + +.global-menu-button svg path { + fill: var(--launcher-pane-text-color); +} + +.global-menu-button:hover { border: 0; } +.global-menu-button:hover svg path { + transition: 200ms ease-in-out fill; +} +.global-menu-button:hover svg path.st0 { fill:#95C980; } +.global-menu-button:hover svg path.st1 { fill:#72B755; } +.global-menu-button:hover svg path.st2 { fill:#4FA52B; } +.global-menu-button:hover svg path.st3 { fill:#EE8C89; } +.global-menu-button:hover svg path.st4 { fill:#E96562; } +.global-menu-button:hover svg path.st5 { fill:#E33F3B; } +.global-menu-button:hover svg path.st6 { fill:#EFB075; } +.global-menu-button:hover svg path.st7 { fill:#E99547; } +.global-menu-button:hover svg path.st8 { fill:#E47B19; } + +.global-menu-button-update-available { + position: absolute; + right: -30px; + bottom: -30px; + width: 100%; + height: 100%; + pointer-events: none; +} + +.global-menu .zoom-container { + display: flex; + flex-direction: row; + align-items: baseline; +} + +.global-menu .zoom-buttons { + margin-left: 2em; +} + +.global-menu .zoom-buttons a { + display: inline-block; + border: 1px solid var(--button-border-color); + border-radius: var(--button-border-radius); + color: var(--button-text-color); + background-color: var(--button-background-color); + padding: 3px; + margin-left: 3px; + text-decoration: none; +} + +.global-menu .zoom-buttons a:hover { + text-decoration: none; +} + +.global-menu .zoom-state { + margin-left: 5px; + margin-right: 5px; +} + +.global-menu .dropdown-item .bx { + position: relative; + top: 3px; + font-size: 120%; + margin-right: 6px; +} \ No newline at end of file diff --git a/apps/client/src/widgets/buttons/global_menu.ts b/apps/client/src/widgets/buttons/global_menu.ts deleted file mode 100644 index 9d6493b89..000000000 --- a/apps/client/src/widgets/buttons/global_menu.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { t } from "../../services/i18n.js"; -import BasicWidget from "../basic_widget.js"; -import utils from "../../services/utils.js"; -import UpdateAvailableWidget from "./update_available.js"; -import options from "../../services/options.js"; -import { Tooltip, Dropdown } from "bootstrap"; - -const TPL = /*html*/` - -`; - -export default class GlobalMenuWidget extends BasicWidget { - private updateAvailableWidget: UpdateAvailableWidget; - private isHorizontalLayout: boolean; - private tooltip!: Tooltip; - private dropdown!: Dropdown; - - private $updateToLatestVersionButton!: JQuery; - private $zoomState!: JQuery; - private $toggleZenMode!: JQuery; - - constructor(isHorizontalLayout: boolean) { - super(); - - this.updateAvailableWidget = new UpdateAvailableWidget(); - this.isHorizontalLayout = isHorizontalLayout; - } - - doRender() { - this.$widget = $(TPL); - - if (!this.isHorizontalLayout) { - this.$widget.addClass("dropend"); - } - - const $globalMenuButton = this.$widget.find(".global-menu-button"); - if (!this.isHorizontalLayout) { - $globalMenuButton.prepend( - $(`\ - - - - - - - - - - - - - - - `) - ); - - this.tooltip = new Tooltip(this.$widget.find("[data-bs-toggle='tooltip']")[0], { trigger: "hover" }); - } else { - $globalMenuButton.toggleClass("bx bx-menu"); - } - - this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0], { - popperConfig: { - placement: "bottom" - } - }); - - this.$widget.find(".show-about-dialog-button").on("click", () => this.triggerCommand("openAboutDialog")); - - const isElectron = utils.isElectron(); - - this.$widget.find(".toggle-pin").toggle(isElectron); - if (isElectron) { - this.$widget.on("click", ".toggle-pin", (e) => { - const $el = $(e.target); - const remote = utils.dynamicRequire("@electron/remote"); - const focusedWindow = remote.BrowserWindow.getFocusedWindow(); - const isAlwaysOnTop = focusedWindow.isAlwaysOnTop(); - if (isAlwaysOnTop) { - focusedWindow.setAlwaysOnTop(false); - $el.removeClass("active"); - } else { - focusedWindow.setAlwaysOnTop(true); - $el.addClass("active"); - } - }); - } - - this.$widget.find(".logout-button").toggle(!isElectron); - this.$widget.find(".logout-button-separator").toggle(!isElectron); - - this.$widget.find(".open-dev-tools-button").toggle(isElectron); - this.$widget.find(".switch-to-mobile-version-button").toggle(!isElectron && utils.isDesktop()); - this.$widget.find(".switch-to-desktop-version-button").toggle(!isElectron && utils.isMobile()); - - this.$widget.on("click", ".dropdown-item", (e) => { - if ($(e.target).parent(".zoom-buttons")) { - return; - } - - this.dropdown.toggle(); - }); - if (utils.isMobile()) { - this.$widget.on("click", ".dropdown-submenu .dropdown-toggle", (e) => { - const $submenu = $(e.target).closest(".dropdown-item"); - $submenu.toggleClass("submenu-open"); - $submenu.find("ul.dropdown-menu").toggleClass("show"); - e.stopPropagation(); - return; - }); - } - this.$widget.on("click", ".dropdown-submenu", (e) => { - if ($(e.target).children(".dropdown-menu").length === 1 || $(e.target).hasClass("dropdown-toggle")) { - e.stopPropagation(); - } - }); - - this.$widget.find(".global-menu-button-update-available").append(this.updateAvailableWidget.render()); - - this.$updateToLatestVersionButton = this.$widget.find(".update-to-latest-version-button"); - - if (!utils.isElectron()) { - this.$widget.find(".zoom-container").hide(); - } - - this.$zoomState = this.$widget.find(".zoom-state"); - this.$toggleZenMode = this.$widget.find('[data-trigger-command="toggleZenMode"'); - this.$widget.on("show.bs.dropdown", () => this.#onShown()); - if (this.tooltip) { - this.$widget.on("hide.bs.dropdown", () => this.tooltip.enable()); - } - - this.$widget.find(".zoom-buttons").on( - "click", - // delay to wait for the actual zoom change - () => setTimeout(() => this.updateZoomState(), 300) - ); - - this.updateVersionStatus(); - - setInterval(() => this.updateVersionStatus(), 8 * 60 * 60 * 1000); - } - - #onShown() { - this.$toggleZenMode.toggleClass("active", $("body").hasClass("zen")); - this.updateZoomState(); - if (this.tooltip) { - this.tooltip.hide(); - this.tooltip.disable(); - } - } - - updateZoomState() { - if (!utils.isElectron()) { - return; - } - - const zoomFactor = utils.dynamicRequire("electron").webFrame.getZoomFactor(); - const zoomPercent = Math.round(zoomFactor * 100); - - this.$zoomState.text(`${zoomPercent}%`); - } - - async updateVersionStatus() { - await options.initializedPromise; - - if (options.get("checkForUpdates") !== "true") { - return; - } - - const latestVersion = await this.fetchLatestVersion(); - this.updateAvailableWidget.updateVersionStatus(latestVersion); - // Show "click to download" button in options menu if there's a new version available - this.$updateToLatestVersionButton.toggle(utils.isUpdateAvailable(latestVersion, glob.triliumVersion)); - this.$updateToLatestVersionButton.find(".version-text").text(`Version ${latestVersion} is available, click to download.`); - } - - async fetchLatestVersion() { - const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Trilium/releases/latest"; - - const resp = await fetch(RELEASES_API_URL); - const data = await resp.json(); - - return data?.tag_name?.substring(1); - } - - downloadLatestVersionCommand() { - window.open("https://github.com/TriliumNext/Trilium/releases/latest"); - } - - activeContextChangedEvent() { - this.dropdown.hide(); - } - - noteSwitchedEvent() { - this.dropdown.hide(); - } -} diff --git a/apps/client/src/widgets/buttons/global_menu.tsx b/apps/client/src/widgets/buttons/global_menu.tsx new file mode 100644 index 000000000..344572c1c --- /dev/null +++ b/apps/client/src/widgets/buttons/global_menu.tsx @@ -0,0 +1,133 @@ +import Dropdown from "../react/Dropdown"; +import "./global_menu.css"; +import { useStaticTooltip, useStaticTooltipWithKeyboardShortcut } from "../react/hooks"; +import { useContext, useEffect, useRef, useState } from "preact/hooks"; +import { t } from "../../services/i18n"; +import { FormDropdownDivider, FormListItem } from "../react/FormList"; +import { CommandNames } from "../../components/app_context"; +import KeyboardShortcut from "../react/KeyboardShortcut"; +import { KeyboardActionNames } from "@triliumnext/commons"; +import { ComponentChildren } from "preact"; +import Component from "../../components/component"; +import { ParentComponent } from "../react/react_utils"; +import { dynamicRequire, isElectron } from "../../services/utils"; + +interface MenuItemProps { + icon: string, + text: ComponentChildren, + title?: string, + command: T, + disabled?: boolean +} + +export default function GlobalMenu({ isHorizontalLayout }: { isHorizontalLayout: boolean }) { + const isVerticalLayout = !isHorizontalLayout; + const parentComponent = useContext(ParentComponent); + + return ( + } + forceShown + > + + + + + + + + + ) +} + +function MenuItem({ icon, text, title, command, disabled }: MenuItemProps void)>) { + return {text} +} + +function KeyboardAction({ text, command, ...props }: MenuItemProps) { + return {text} } + /> +} + +function VerticalLayoutIcon() { + const logoRef = useRef(null); + useStaticTooltip(logoRef); + + return ( + + + + + + + + + + + + + + + + ) +} + +function ZoomControls({ parentComponent }: { parentComponent?: Component | null }) { + const [ zoomLevel, setZoomLevel ] = useState(100); + + function updateZoomState() { + if (!isElectron()) { + return; + } + + const zoomFactor = dynamicRequire("electron").webFrame.getZoomFactor(); + setZoomLevel(Math.round(zoomFactor * 100)); + } + + useEffect(updateZoomState, []); + + function ZoomControlButton({ command, title, icon, children }: { command: KeyboardActionNames, title: string, icon?: string, children?: ComponentChildren }) { + const linkRef = useRef(null); + useStaticTooltipWithKeyboardShortcut(linkRef, title, command); + return ( + { + parentComponent?.triggerCommand(command); + setTimeout(() => updateZoomState(), 300) + e.stopPropagation(); + }} + className={icon} + >{children} + ) + } + + return isElectron() ? ( + +
+ +   + + {zoomLevel}{t("units.percentage")} + +
+ } + >{t("global_menu.zoom")}
+ ) : ( + + ); +} diff --git a/apps/client/src/widgets/react/Dropdown.tsx b/apps/client/src/widgets/react/Dropdown.tsx index fbcd6a78e..91403b622 100644 --- a/apps/client/src/widgets/react/Dropdown.tsx +++ b/apps/client/src/widgets/react/Dropdown.tsx @@ -18,9 +18,10 @@ export interface DropdownProps { noSelectButtonStyle?: boolean; disabled?: boolean; text?: ComponentChildren; + forceShown?: boolean; } -export default function Dropdown({ className, buttonClassName, isStatic, children, title, text, dropdownContainerStyle, dropdownContainerClassName, hideToggleArrow, iconAction, disabled, noSelectButtonStyle }: DropdownProps) { +export default function Dropdown({ className, buttonClassName, isStatic, children, title, text, dropdownContainerStyle, dropdownContainerClassName, hideToggleArrow, iconAction, disabled, noSelectButtonStyle, forceShown }: DropdownProps) { const dropdownRef = useRef(null); const triggerRef = useRef(null); @@ -30,8 +31,12 @@ export default function Dropdown({ className, buttonClassName, isStatic, childre if (!triggerRef.current) return; const dropdown = BootstrapDropdown.getOrCreateInstance(triggerRef.current); + if (forceShown) { + dropdown.show(); + setShown(true); + } return () => dropdown.dispose(); - }, []); // Add dependency array + }, []); const onShown = useCallback(() => { setShown(true); diff --git a/apps/client/src/widgets/react/FormList.tsx b/apps/client/src/widgets/react/FormList.tsx index c12c8a317..5e3bae3cf 100644 --- a/apps/client/src/widgets/react/FormList.tsx +++ b/apps/client/src/widgets/react/FormList.tsx @@ -87,16 +87,17 @@ interface FormListItemOpts { description?: string; className?: string; rtl?: boolean; + outsideChildren?: ComponentChildren; } -export function FormListItem({ children, icon, value, title, active, badges, disabled, checked, onClick, description, selected, rtl, triggerCommand }: FormListItemOpts) { +export function FormListItem({ className, children, icon, value, title, active, badges, disabled, checked, onClick, description, selected, rtl, triggerCommand, outsideChildren }: FormListItemOpts) { if (checked) { icon = "bx bx-check"; } return ( {description}} + {outsideChildren} ); } diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index af5378144..bd0c2e287 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -2,7 +2,7 @@ import { useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, use import { EventData, EventNames } from "../../components/app_context"; import { ParentComponent } from "./react_utils"; import SpacedUpdate from "../../services/spaced_update"; -import { OptionNames } from "@triliumnext/commons"; +import { KeyboardActionNames, OptionNames } from "@triliumnext/commons"; import options, { type OptionValue } from "../../services/options"; import utils, { reloadFrontendApp } from "../../services/utils"; import NoteContext from "../../components/note_context"; @@ -14,6 +14,7 @@ import NoteContextAwareWidget from "../note_context_aware_widget"; import { RefObject, VNode } from "preact"; import { Tooltip } from "bootstrap"; import { CSSProperties } from "preact/compat"; +import keyboard_actions from "../../services/keyboard_actions"; export function useTriliumEvent(eventName: T, handler: (data: EventData) => void) { const parentComponent = useContext(ParentComponent); @@ -502,7 +503,7 @@ export function useTooltip(elRef: RefObject, config: Partial, config?: Partial) { +export function useStaticTooltip(elRef: RefObject, config?: Partial) { useEffect(() => { if (!elRef?.current) return; @@ -514,6 +515,19 @@ export function useStaticTooltip(elRef: RefObject, config?: Partial }, [ elRef, config ]); } +export function useStaticTooltipWithKeyboardShortcut(elRef: RefObject, title: string, actionName: KeyboardActionNames) { + const [ keyboardShortcut, setKeyboardShortcut ] = useState(); + useStaticTooltip(elRef, { + title: keyboardShortcut?.length ? `${title} (${keyboardShortcut?.join(",")})` : title + }); + + useEffect(() => { + if (actionName) { + keyboard_actions.getAction(actionName).then(action => setKeyboardShortcut(action?.effectiveShortcuts)); + } + }, [actionName]); +} + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type export function useLegacyImperativeHandlers(handlers: Record) { const parentComponent = useContext(ParentComponent); diff --git a/apps/client/src/widgets/ribbon/Ribbon.tsx b/apps/client/src/widgets/ribbon/Ribbon.tsx index 53813b1f4..4c1b6a12b 100644 --- a/apps/client/src/widgets/ribbon/Ribbon.tsx +++ b/apps/client/src/widgets/ribbon/Ribbon.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { t } from "../../services/i18n"; -import { useNoteContext, useNoteProperty, useStaticTooltip, useTooltip, useTriliumEvent, useTriliumEvents } from "../react/hooks"; +import { useNoteContext, useNoteProperty, useStaticTooltip, useStaticTooltipWithKeyboardShortcut, useTooltip, useTriliumEvent, useTriliumEvents } from "../react/hooks"; import "./style.css"; import { VNode } from "preact"; import BasicPropertiesTab from "./BasicPropertiesTab"; @@ -252,16 +252,7 @@ export default function Ribbon() { function RibbonTab({ icon, title, active, onClick, toggleCommand }: { icon: string; title: string; active: boolean, onClick: () => void, toggleCommand?: KeyboardActionNames }) { const iconRef = useRef(null); - const [ keyboardShortcut, setKeyboardShortcut ] = useState(); - useStaticTooltip(iconRef, { - title: keyboardShortcut?.length ? `${title} (${keyboardShortcut?.join(",")})` : title - }); - - useEffect(() => { - if (toggleCommand) { - keyboard_actions.getAction(toggleCommand).then(action => setKeyboardShortcut(action?.effectiveShortcuts)); - } - }, [toggleCommand]); + useStaticTooltipWithKeyboardShortcut(iconRef, title, toggleCommand); return ( <>