diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index ca4f9745f..d888eba6f 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -1,6 +1,6 @@ import froca from "../services/froca.js"; import RootCommandExecutor from "./root_command_executor.js"; -import Entrypoints, { type SqlExecuteResults } from "./entrypoints.js"; +import Entrypoints from "./entrypoints.js"; import options from "../services/options.js"; import utils, { hasTouchBar } from "../services/utils.js"; import zoomComponent from "./zoom.js"; @@ -32,6 +32,7 @@ import type { CreateNoteOpts } from "../services/note_create.js"; import { ColumnComponent } from "tabulator-tables"; import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx"; import type RootContainer from "../widgets/containers/root_container.js"; +import { SqlExecuteResults } from "@triliumnext/commons"; interface Layout { getRootWidget: (appContext: AppContext) => RootContainer; @@ -89,6 +90,11 @@ export type CommandMappings = { closeTocCommand: CommandData; closeHlt: CommandData; showLaunchBarSubtree: CommandData; + showHiddenSubtree: CommandData; + showSQLConsoleHistory: CommandData; + logout: CommandData; + switchToMobileVersion: CommandData; + switchToDesktopVersion: CommandData; showRevisions: CommandData & { noteId?: string | null; }; @@ -134,6 +140,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/components/entrypoints.ts b/apps/client/src/components/entrypoints.ts index 2e55a9b9d..7989960a6 100644 --- a/apps/client/src/components/entrypoints.ts +++ b/apps/client/src/components/entrypoints.ts @@ -10,22 +10,7 @@ import bundleService from "../services/bundle.js"; import froca from "../services/froca.js"; import linkService from "../services/link.js"; import { t } from "../services/i18n.js"; -import type FNote from "../entities/fnote.js"; - -// TODO: Move somewhere else nicer. -export type SqlExecuteResults = string[][][]; - -// TODO: Deduplicate with server. -interface SqlExecuteResponse { - success: boolean; - error?: string; - results: SqlExecuteResults; -} - -// TODO: Deduplicate with server. -interface CreateChildrenResponse { - note: FNote; -} +import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons"; export default class Entrypoints extends Component { constructor() { @@ -34,7 +19,7 @@ export default class Entrypoints extends Component { openDevToolsCommand() { if (utils.isElectron()) { - utils.dynamicRequire("@electron/remote").getCurrentWindow().toggleDevTools(); + utils.dynamicRequire("@electron/remote").getCurrentWindow().webContents.toggleDevTools(); } } @@ -124,7 +109,7 @@ export default class Entrypoints extends Component { if (utils.isElectron()) { // standard JS version does not work completely correctly in electron const webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents(); - const activeIndex = parseInt(webContents.navigationHistory.getActiveIndex()); + const activeIndex = webContents.navigationHistory.getActiveIndex(); webContents.goToIndex(activeIndex - 1); } else { @@ -136,7 +121,7 @@ export default class Entrypoints extends Component { if (utils.isElectron()) { // standard JS version does not work completely correctly in electron const webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents(); - const activeIndex = parseInt(webContents.navigationHistory.getActiveIndex()); + const activeIndex = webContents.navigationHistory.getActiveIndex(); webContents.goToIndex(activeIndex + 1); } else { diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index f9ad398e1..ab189f90d 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -1,24 +1,18 @@ 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"; import NoteTreeWidget from "../widgets/note_tree.js"; import NoteTitleWidget from "../widgets/note_title.jsx"; import NoteDetailWidget from "../widgets/note_detail.js"; import PromotedAttributesWidget from "../widgets/promoted_attributes.js"; import NoteListWidget from "../widgets/note_list.js"; -import SqlResultWidget from "../widgets/sql_result.js"; -import SqlTableSchemasWidget from "../widgets/sql_table_schemas.js"; import NoteIconWidget from "../widgets/note_icon.jsx"; -import SearchResultWidget from "../widgets/search_result.js"; import ScrollingContainer from "../widgets/containers/scrolling_container.js"; import RootContainer from "../widgets/containers/root_container.js"; import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js"; import SpacerWidget from "../widgets/spacer.js"; import QuickSearchWidget from "../widgets/quick_search.js"; import SplitNoteContainer from "../widgets/containers/split_note_container.js"; -import LeftPaneToggleWidget from "../widgets/buttons/left_pane_toggle.js"; import CreatePaneButton from "../widgets/buttons/create_pane_button.js"; import ClosePaneButton from "../widgets/buttons/close_pane_button.js"; import RightPaneContainer from "../widgets/containers/right_pane_container.js"; @@ -29,19 +23,25 @@ import TocWidget from "../widgets/toc.js"; import HighlightsListWidget from "../widgets/highlights_list.js"; import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js"; import LauncherContainer from "../widgets/containers/launcher_container.js"; -import ApiLogWidget from "../widgets/api_log.js"; import MovePaneButton from "../widgets/buttons/move_pane_button.js"; import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js"; -import ScrollPaddingWidget from "../widgets/scroll_padding.js"; +import ScrollPadding from "../widgets/scroll_padding.js"; import options from "../services/options.js"; import utils from "../services/utils.js"; -import CloseZenButton from "../widgets/close_zen_button.js"; import type { AppContext } from "../components/app_context.js"; import type { WidgetsByParent } from "../services/bundle.js"; import { applyModals } from "./layout_commons.js"; 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"; +import SqlResults from "../widgets/sql_result.js"; +import SqlTableSchemas from "../widgets/sql_table_schemas.js"; +import TitleBarButtons from "../widgets/title_bar_buttons.jsx"; +import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js"; +import ApiLog from "../widgets/api_log.jsx"; +import CloseZenModeButton from "../widgets/close_zen_button.jsx"; export default class DesktopLayout { @@ -76,9 +76,9 @@ export default class DesktopLayout { new FlexContainer("row") .class("tab-row-container") .child(new FlexContainer("row").id("tab-row-left-spacer")) - .optChild(launcherPaneIsHorizontal, new LeftPaneToggleWidget(true)) + .optChild(launcherPaneIsHorizontal, ) .child(new TabRowWidget().class("full-width")) - .optChild(customTitleBarButtons, new TitleBarButtonsWidget()) + .optChild(customTitleBarButtons, ) .css("height", "40px") .css("background-color", "var(--launcher-pane-background-color)") .setParent(appContext) @@ -99,7 +99,7 @@ export default class DesktopLayout { new FlexContainer("column") .id("rest-pane") .css("flex-grow", "1") - .optChild(!fullWidthTabBar, new FlexContainer("row").child(new TabRowWidget()).optChild(customTitleBarButtons, new TitleBarButtonsWidget()).css("height", "40px")) + .optChild(!fullWidthTabBar, new FlexContainer("row").child(new TabRowWidget()).optChild(customTitleBarButtons, ).css("height", "40px")) .child( new FlexContainer("row") .filling() @@ -136,14 +136,14 @@ export default class DesktopLayout { new ScrollingContainer() .filling() .child(new PromotedAttributesWidget()) - .child(new SqlTableSchemasWidget()) + .child() .child(new NoteDetailWidget()) .child(new NoteListWidget(false)) - .child(new SearchResultWidget()) - .child(new SqlResultWidget()) - .child(new ScrollPaddingWidget()) + .child() + .child() + .child() ) - .child(new ApiLogWidget()) + .child() .child(new FindWidget()) .child( ...this.customWidgets.get("node-detail-pane"), // typo, let's keep it for a while as BC @@ -162,7 +162,7 @@ export default class DesktopLayout { ) ) ) - .child(new CloseZenButton()) + .child() // Desktop-specific dialogs. .child() @@ -176,14 +176,18 @@ 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)); + .child(); } launcherPane.id("launcher-pane"); diff --git a/apps/client/src/layouts/mobile_layout.tsx b/apps/client/src/layouts/mobile_layout.tsx index 18c3d4a2e..cc721fa9a 100644 --- a/apps/client/src/layouts/mobile_layout.tsx +++ b/apps/client/src/layouts/mobile_layout.tsx @@ -3,8 +3,6 @@ import NoteTitleWidget from "../widgets/note_title.js"; import NoteDetailWidget from "../widgets/note_detail.js"; import QuickSearchWidget from "../widgets/quick_search.js"; import NoteTreeWidget from "../widgets/note_tree.js"; -import ToggleSidebarButtonWidget from "../widgets/mobile_widgets/toggle_sidebar_button.js"; -import MobileDetailMenuWidget from "../widgets/mobile_widgets/mobile_detail_menu.js"; import ScreenContainer from "../widgets/mobile_widgets/screen_container.js"; import ScrollingContainer from "../widgets/containers/scrolling_container.js"; import NoteListWidget from "../widgets/note_list.js"; @@ -18,11 +16,13 @@ import type AppContext from "../components/app_context.js"; import TabRowWidget from "../widgets/tab_row.js"; import MobileEditorToolbar from "../widgets/type_widgets/ckeditor/mobile_editor_toolbar.js"; import { applyModals } from "./layout_commons.js"; -import CloseZenButton from "../widgets/close_zen_button.js"; import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx"; import { useNoteContext } from "../widgets/react/hooks.jsx"; import FloatingButtons from "../widgets/FloatingButtons.jsx"; import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx"; +import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx"; +import CloseZenModeButton from "../widgets/close_zen_button.js"; +import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js"; const MOBILE_CSS = ` - -
- -
-`; - -export default class ApiLogWidget extends NoteContextAwareWidget { - - private $logContainer!: JQuery; - private $closeButton!: JQuery; - - isEnabled() { - return !!this.note && this.note.mime.startsWith("application/javascript;env=") && super.isEnabled(); - } - - doRender() { - this.$widget = $(TPL); - this.toggle(false); - - this.$logContainer = this.$widget.find(".api-log-container"); - this.$closeButton = this.$widget.find(".close-api-log-button"); - this.$closeButton.on("click", () => this.toggle(false)); - } - - async refreshWithNote(note: FNote) { - this.$logContainer.empty(); - } - - apiLogMessagesEvent({ messages, noteId }: EventData<"apiLogMessages">) { - if (!this.isNote(noteId)) { - return; - } - - this.toggle(true); - - for (const message of messages) { - this.$logContainer.append(message).append($("
")); - } - } - - toggle(show: boolean) { - this.$widget.toggleClass("hidden-api-log", !show); - } -} diff --git a/apps/client/src/widgets/api_log.tsx b/apps/client/src/widgets/api_log.tsx new file mode 100644 index 000000000..ba0f8e095 --- /dev/null +++ b/apps/client/src/widgets/api_log.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from "preact/hooks"; +import "./api_log.css"; +import { useNoteContext, useTriliumEvent } from "./react/hooks"; +import ActionButton from "./react/ActionButton"; +import { t } from "../services/i18n"; + +/** + * Displays the messages that are logged by the current note via `api.log`, for frontend and backend scripts. + */ +export default function ApiLog() { + const { note, noteId } = useNoteContext(); + const [ messages, setMessages ] = useState(); + + useTriliumEvent("apiLogMessages", ({ messages, noteId: eventNoteId }) => { + if (eventNoteId !== noteId) return; + setMessages(messages); + }); + + // Clear when navigating away. + useEffect(() => setMessages(undefined), [ note ]); + + const isEnabled = note?.mime.startsWith("application/javascript;env=") && messages?.length; + return ( +
+ {isEnabled && ( + <> + setMessages(undefined)} + /> + +
+ {messages.join("\n")} +
+ + )} +
+ ) +} diff --git a/apps/client/src/widgets/basic_widget.ts b/apps/client/src/widgets/basic_widget.ts index f49f2382c..e33267daf 100644 --- a/apps/client/src/widgets/basic_widget.ts +++ b/apps/client/src/widgets/basic_widget.ts @@ -55,7 +55,7 @@ export class TypedBasicWidget> extends TypedCompon * @param components the components to be added as children to this component provided the condition is truthy. * @returns self for chaining. */ - optChild(condition: boolean, ...components: T[]) { + optChild(condition: boolean, ...components: (T | VNode)[]) { if (condition) { return this.child(...components); } else { 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..920308dea --- /dev/null +++ b/apps/client/src/widgets/buttons/global_menu.css @@ -0,0 +1,102 @@ +.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; +} + +/* #region Update available */ +.global-menu-button-update-available-button { + width: 21px !important; + height: 21px !important; + padding: 0 !important; + + border-radius: var(--button-border-radius); + transform: scale(0.9); + border: none; + opacity: 0.8; + + display: flex; + align-items: center; + justify-content: center; +} + +.global-menu-button-wrapper:hover .global-menu-button-update-available-button { + opacity: 1; +} +/* #endregion */ \ 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..fc74166ba --- /dev/null +++ b/apps/client/src/widgets/buttons/global_menu.tsx @@ -0,0 +1,239 @@ +import Dropdown from "../react/Dropdown"; +import "./global_menu.css"; +import { useStaticTooltip, useStaticTooltipWithKeyboardShortcut, useTriliumOption, useTriliumOptionBool } from "../react/hooks"; +import { useContext, useEffect, useRef, useState } from "preact/hooks"; +import { t } from "../../services/i18n"; +import { FormDropdownDivider, FormDropdownSubmenu, 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 utils, { dynamicRequire, isElectron, isMobile } from "../../services/utils"; + +interface MenuItemProps { + icon: string, + text: ComponentChildren, + title?: string, + command: T, + disabled?: boolean + active?: boolean; + outsideChildren?: ComponentChildren; +} + +export default function GlobalMenu({ isHorizontalLayout }: { isHorizontalLayout: boolean }) { + const isVerticalLayout = !isHorizontalLayout; + const parentComponent = useContext(ParentComponent); + const { isUpdateAvailable, latestVersion } = useTriliumUpdateStatus(); + + return ( + + {isVerticalLayout && } + {isUpdateAvailable && } + } + > + + + + + + + + + + + + + + + + + + + + {isUpdateAvailable && window.open("https://github.com/TriliumNext/Trilium/releases/latest")} icon="bx bx-sync" text={`Version ${latestVersion} is available, click to download.`} /> } + {!isElectron() && } + + ) +} + +function AdvancedMenu() { + return ( + + + + + + + + + + + {isElectron() && } + + + ) +} + +function BrowserOnlyOptions() { + return <> + + + ; +} + +function SwitchToOptions() { + if (isElectron()) { + return; + } else if (!isMobile()) { + return + } else { + return + } +} + +function MenuItem({ icon, text, title, command, disabled, active }: MenuItemProps void)>) { + return {text} +} + +function KeyboardActionMenuItem({ 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() ? ( + + {t("global_menu.zoom")} + <> +
+ +   + + {zoomLevel}{t("units.percentage")} + +
+ +
+ ) : ( + + ); +} + +function ToggleWindowOnTop() { + const focusedWindow = isElectron() ? dynamicRequire("@electron/remote").BrowserWindow.getFocusedWindow() : null; + const [ isAlwaysOnTop, setIsAlwaysOnTop ] = useState(focusedWindow?.isAlwaysOnTop()); + + return (isElectron() && + { + const newState = !isAlwaysOnTop; + focusedWindow?.setAlwaysOnTop(newState); + setIsAlwaysOnTop(newState); + }} + /> + ) +} + +function useTriliumUpdateStatus() { + const [ latestVersion, setLatestVersion ] = useState(); + const [ checkForUpdates ] = useTriliumOptionBool("checkForUpdates"); + const isUpdateAvailable = utils.isUpdateAvailable(latestVersion, glob.triliumVersion); + + async function updateVersionStatus() { + 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(); + const latestVersion = data?.tag_name?.substring(1); + setLatestVersion(latestVersion); + } + + useEffect(() => { + if (!checkForUpdates) { + setLatestVersion(undefined); + return; + } + + updateVersionStatus(); + + const interval = setInterval(() => updateVersionStatus(), 8 * 60 * 60 * 1000); + return () => clearInterval(interval); + }, [ checkForUpdates ]); + + return { isUpdateAvailable, latestVersion }; +} \ No newline at end of file diff --git a/apps/client/src/widgets/buttons/history_navigation.ts b/apps/client/src/widgets/buttons/history_navigation.ts index 9fa54c60a..5c05defb6 100644 --- a/apps/client/src/widgets/buttons/history_navigation.ts +++ b/apps/client/src/widgets/buttons/history_navigation.ts @@ -4,15 +4,7 @@ import treeService from "../../services/tree.js"; import ButtonFromNoteWidget from "./button_from_note.js"; import type FNote from "../../entities/fnote.js"; import type { CommandNames } from "../../components/app_context.js"; - -interface WebContents { - history: string[]; - getActiveIndex(): number; - clearHistory(): void; - canGoBack(): boolean; - canGoForward(): boolean; - goToIndex(index: string): void; -} +import type { WebContents } from "electron"; interface ContextMenuItem { title: string; @@ -51,14 +43,14 @@ export default class HistoryNavigationButton extends ButtonFromNoteWidget { async showContextMenu(e: JQuery.ContextMenuEvent) { e.preventDefault(); - if (!this.webContents || this.webContents.history.length < 2) { + if (!this.webContents || this.webContents.navigationHistory.length() < 2) { return; } let items: ContextMenuItem[] = []; - const activeIndex = this.webContents.getActiveIndex(); - const history = this.webContents.history; + const history = this.webContents.navigationHistory; + const activeIndex = history.getActiveIndex(); for (const idx in history) { const url = history[idx]; diff --git a/apps/client/src/widgets/buttons/left_pane_toggle.ts b/apps/client/src/widgets/buttons/left_pane_toggle.ts deleted file mode 100644 index 22a902622..000000000 --- a/apps/client/src/widgets/buttons/left_pane_toggle.ts +++ /dev/null @@ -1,43 +0,0 @@ -import options from "../../services/options.js"; -import splitService from "../../services/resizer.js"; -import CommandButtonWidget from "./command_button.js"; -import { t } from "../../services/i18n.js"; -import type { EventData } from "../../components/app_context.js"; - -export default class LeftPaneToggleWidget extends CommandButtonWidget { - private currentLeftPaneVisible: boolean; - - constructor(isHorizontalLayout: boolean) { - super(); - - this.currentLeftPaneVisible = options.is("leftPaneVisible"); - - this.class(isHorizontalLayout ? "toggle-button" : "launcher-button"); - - this.settings.icon = () => { - if (options.get("layoutOrientation") === "horizontal") { - return "bx-sidebar"; - } - - return this.currentLeftPaneVisible ? "bx-chevrons-left" : "bx-chevrons-right"; - }; - - this.settings.title = () => (this.currentLeftPaneVisible ? t("left_pane_toggle.hide_panel") : t("left_pane_toggle.show_panel")); - - this.settings.command = () => (this.currentLeftPaneVisible ? "hideLeftPane" : "showLeftPane"); - - if (isHorizontalLayout) { - this.settings.titlePlacement = "bottom"; - } - } - - refreshIcon() { - super.refreshIcon(); - splitService.setupLeftPaneResizer(this.currentLeftPaneVisible); - } - - setLeftPaneVisibilityEvent({ leftPaneVisible }: EventData<"setLeftPaneVisibility">) { - this.currentLeftPaneVisible = leftPaneVisible ?? !this.currentLeftPaneVisible; - this.refreshIcon(); - } -} diff --git a/apps/client/src/widgets/buttons/left_pane_toggle.tsx b/apps/client/src/widgets/buttons/left_pane_toggle.tsx new file mode 100644 index 000000000..f6c4b3ab8 --- /dev/null +++ b/apps/client/src/widgets/buttons/left_pane_toggle.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from "preact/hooks"; +import ActionButton from "../react/ActionButton"; +import options from "../../services/options"; +import { t } from "../../services/i18n"; +import { useTriliumEvent } from "../react/hooks"; +import resizer from "../../services/resizer"; + +export default function LeftPaneToggle({ isHorizontalLayout }: { isHorizontalLayout: boolean }) { + const [ currentLeftPaneVisible, setCurrentLeftPaneVisible ] = useState(options.is("leftPaneVisible")); + + useTriliumEvent("setLeftPaneVisibility", ({ leftPaneVisible }) => { + setCurrentLeftPaneVisible(leftPaneVisible ?? !currentLeftPaneVisible); + }); + + useEffect(() => { + resizer.setupLeftPaneResizer(currentLeftPaneVisible); + }, [ currentLeftPaneVisible ]); + + return ( + + ) +} \ No newline at end of file diff --git a/apps/client/src/widgets/buttons/update_available.ts b/apps/client/src/widgets/buttons/update_available.ts deleted file mode 100644 index 2f2535cc0..000000000 --- a/apps/client/src/widgets/buttons/update_available.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { t } from "../../services/i18n.js"; -import BasicWidget from "../basic_widget.js"; -import utils from "../../services/utils.js"; - -const TPL = /*html*/` -
- - - -
-`; - -export default class UpdateAvailableWidget extends BasicWidget { - doRender() { - this.$widget = $(TPL); - } - - updateVersionStatus(latestVersion: string) { - this.$widget.toggle(utils.isUpdateAvailable(latestVersion, glob.triliumVersion)); - } -} diff --git a/apps/client/src/widgets/close_zen_button.css b/apps/client/src/widgets/close_zen_button.css new file mode 100644 index 000000000..3ec9f9315 --- /dev/null +++ b/apps/client/src/widgets/close_zen_button.css @@ -0,0 +1,26 @@ +:root { + --zen-button-size: 32px; +} + +.close-zen-container { + width: var(--zen-button-size); + height: var(--zen-button-size); +} + +body.zen .close-zen-container { + display: block; + position: fixed; + top: 2px; + right: 2px; + z-index: 9999; + -webkit-app-region: no-drag; +} + +body.zen.mobile .close-zen-container { + top: -2px; +} + +body.zen.electron:not(.platform-darwin):not(.native-titlebar) .close-zen-container { + left: calc(env(titlebar-area-width) - var(--zen-button-size) - 2px); + right: unset; +} \ No newline at end of file diff --git a/apps/client/src/widgets/close_zen_button.ts b/apps/client/src/widgets/close_zen_button.ts deleted file mode 100644 index 7ee4fcc28..000000000 --- a/apps/client/src/widgets/close_zen_button.ts +++ /dev/null @@ -1,54 +0,0 @@ -import BasicWidget from "./basic_widget.js"; -import { t } from "../services/i18n.js"; -import utils from "../services/utils.js"; - -const TPL = /*html*/`\ -
-
-`; - -export default class CloseZenButton extends BasicWidget { - - doRender(): void { - this.$widget = $(TPL); - } - - zenChangedEvent() { - this.toggleInt(true); - } - -} diff --git a/apps/client/src/widgets/close_zen_button.tsx b/apps/client/src/widgets/close_zen_button.tsx new file mode 100644 index 000000000..21b364da4 --- /dev/null +++ b/apps/client/src/widgets/close_zen_button.tsx @@ -0,0 +1,25 @@ +import { useState } from "preact/hooks"; +import { t } from "../services/i18n"; +import ActionButton from "./react/ActionButton"; +import { useTriliumEvent } from "./react/hooks"; +import "./close_zen_button.css"; + +export default function CloseZenModeButton() { + const [ zenModeEnabled, setZenModeEnabled ] = useState(false); + + useTriliumEvent("zenModeChanged", ({ isEnabled }) => { + setZenModeEnabled(isEnabled); + }); + + return ( +
+ {zenModeEnabled && ( + + )} +
+ ) +} diff --git a/apps/client/src/widgets/mobile_widgets/mobile_detail_menu.ts b/apps/client/src/widgets/mobile_widgets/mobile_detail_menu.ts deleted file mode 100644 index 434737641..000000000 --- a/apps/client/src/widgets/mobile_widgets/mobile_detail_menu.ts +++ /dev/null @@ -1,64 +0,0 @@ -import BasicWidget from "../basic_widget.js"; -import appContext from "../../components/app_context.js"; -import contextMenu from "../../menus/context_menu.js"; -import noteCreateService from "../../services/note_create.js"; -import branchService from "../../services/branches.js"; -import treeService from "../../services/tree.js"; -import { t } from "../../services/i18n.js"; - -const TPL = /*html*/``; - -class MobileDetailMenuWidget extends BasicWidget { - private isHorizontalLayout: boolean; - - constructor(isHorizontalLayout: boolean) { - super(); - this.isHorizontalLayout = isHorizontalLayout; - } - - doRender() { - this.$widget = $(TPL); - - this.$widget.addClass(this.isHorizontalLayout ? "bx-dots-vertical-rounded" : "bx-menu"); - - this.$widget.on("click", async (e) => { - const note = appContext.tabManager.getActiveContextNote(); - - contextMenu.show<"insertChildNote" | "delete" | "showRevisions">({ - x: e.pageX, - y: e.pageY, - items: [ - { title: t("mobile_detail_menu.insert_child_note"), command: "insertChildNote", uiIcon: "bx bx-plus", enabled: note?.type !== "search" }, - { title: t("mobile_detail_menu.delete_this_note"), command: "delete", uiIcon: "bx bx-trash", enabled: note?.noteId !== "root" }, - { title: "----" }, - { title: "Note revisions", command: "showRevisions", uiIcon: "bx bx-history" } - ], - selectMenuItemHandler: async ({ command }) => { - if (command === "insertChildNote") { - noteCreateService.createNote(appContext.tabManager.getActiveContextNotePath() ?? undefined); - } else if (command === "delete") { - const notePath = appContext.tabManager.getActiveContextNotePath(); - if (!notePath) { - throw new Error("Cannot get note path to delete."); - } - - const branchId = await treeService.getBranchIdFromUrl(notePath); - - if (!branchId) { - throw new Error(t("mobile_detail_menu.error_cannot_get_branch_id", { notePath })); - } - - if (await branchService.deleteNotes([branchId])) { - this.triggerCommand("setActiveScreen", { screen: "tree" }); - } - } else if (command) { - this.triggerCommand(command); - } - }, - forcePositionOnMobile: true - }); - }); - } -} - -export default MobileDetailMenuWidget; diff --git a/apps/client/src/widgets/mobile_widgets/mobile_detail_menu.tsx b/apps/client/src/widgets/mobile_widgets/mobile_detail_menu.tsx new file mode 100644 index 000000000..596a70300 --- /dev/null +++ b/apps/client/src/widgets/mobile_widgets/mobile_detail_menu.tsx @@ -0,0 +1,57 @@ +import { useContext } from "preact/hooks"; +import appContext from "../../components/app_context"; +import contextMenu from "../../menus/context_menu"; +import branches from "../../services/branches"; +import { t } from "../../services/i18n"; +import note_create from "../../services/note_create"; +import tree from "../../services/tree"; +import ActionButton from "../react/ActionButton"; +import { ParentComponent } from "../react/react_utils"; + +export default function MobileDetailMenu() { + const parentComponent = useContext(ParentComponent); + + return ( + { + const note = appContext.tabManager.getActiveContextNote(); + + contextMenu.show<"insertChildNote" | "delete" | "showRevisions">({ + x: e.pageX, + y: e.pageY, + items: [ + { title: t("mobile_detail_menu.insert_child_note"), command: "insertChildNote", uiIcon: "bx bx-plus", enabled: note?.type !== "search" }, + { title: t("mobile_detail_menu.delete_this_note"), command: "delete", uiIcon: "bx bx-trash", enabled: note?.noteId !== "root" }, + { title: "----" }, + { title: "Note revisions", command: "showRevisions", uiIcon: "bx bx-history" } + ], + selectMenuItemHandler: async ({ command }) => { + if (command === "insertChildNote") { + note_create.createNote(appContext.tabManager.getActiveContextNotePath() ?? undefined); + } else if (command === "delete") { + const notePath = appContext.tabManager.getActiveContextNotePath(); + if (!notePath) { + throw new Error("Cannot get note path to delete."); + } + + const branchId = await tree.getBranchIdFromUrl(notePath); + + if (!branchId) { + throw new Error(t("mobile_detail_menu.error_cannot_get_branch_id", { notePath })); + } + + if (await branches.deleteNotes([branchId]) && parentComponent) { + parentComponent.triggerCommand("setActiveScreen", { screen: "tree" }); + } + } else if (command && parentComponent) { + parentComponent.triggerCommand(command); + } + }, + forcePositionOnMobile: true + }); + }} + /> + ) +} diff --git a/apps/client/src/widgets/mobile_widgets/toggle_sidebar_button.ts b/apps/client/src/widgets/mobile_widgets/toggle_sidebar_button.ts deleted file mode 100644 index b2a8039a1..000000000 --- a/apps/client/src/widgets/mobile_widgets/toggle_sidebar_button.ts +++ /dev/null @@ -1,18 +0,0 @@ -import BasicWidget from "../basic_widget.js"; - -const TPL = /*html*/` -`; - -class ToggleSidebarButtonWidget extends BasicWidget { - doRender() { - this.$widget = $(TPL); - - this.$widget.on("click", () => - this.triggerCommand("setActiveScreen", { - screen: "tree" - }) - ); - } -} - -export default ToggleSidebarButtonWidget; diff --git a/apps/client/src/widgets/mobile_widgets/toggle_sidebar_button.tsx b/apps/client/src/widgets/mobile_widgets/toggle_sidebar_button.tsx new file mode 100644 index 000000000..d22f3df8c --- /dev/null +++ b/apps/client/src/widgets/mobile_widgets/toggle_sidebar_button.tsx @@ -0,0 +1,18 @@ +import { useContext } from "preact/hooks"; +import ActionButton from "../react/ActionButton"; +import { ParentComponent } from "../react/react_utils"; +import { t } from "../../services/i18n"; + +export default function ToggleSidebarButton() { + const parentComponent = useContext(ParentComponent); + + return ( + parentComponent?.triggerCommand("setActiveScreen", { + screen: "tree" + })} + /> + ) +} diff --git a/apps/client/src/widgets/react/ActionButton.tsx b/apps/client/src/widgets/react/ActionButton.tsx index 41c4abbed..5e6f3266b 100644 --- a/apps/client/src/widgets/react/ActionButton.tsx +++ b/apps/client/src/widgets/react/ActionButton.tsx @@ -5,7 +5,7 @@ import keyboard_actions from "../../services/keyboard_actions"; export interface ActionButtonProps { text: string; - titlePosition?: "bottom" | "left"; // TODO: Use it + titlePosition?: "bottom" | "left"; icon: string; className?: string; onClick?: (e: MouseEvent) => void; @@ -25,7 +25,7 @@ export default function ActionButton({ text, icon, className, onClick, triggerCo useEffect(() => { if (triggerCommand) { - keyboard_actions.getAction(triggerCommand).then(action => setKeyboardShortcut(action?.effectiveShortcuts)); + keyboard_actions.getAction(triggerCommand, true).then(action => setKeyboardShortcut(action?.effectiveShortcuts)); } }, [triggerCommand]); diff --git a/apps/client/src/widgets/react/Alert.tsx b/apps/client/src/widgets/react/Alert.tsx index 8b8afb68b..e57960157 100644 --- a/apps/client/src/widgets/react/Alert.tsx +++ b/apps/client/src/widgets/react/Alert.tsx @@ -4,11 +4,12 @@ interface AlertProps { type: "info" | "danger" | "warning"; title?: string; children: ComponentChildren; + className?: string; } -export default function Alert({ title, type, children }: AlertProps) { +export default function Alert({ title, type, children, className }: AlertProps) { return ( -
+
{title &&

{title}

} {children} diff --git a/apps/client/src/widgets/react/Dropdown.tsx b/apps/client/src/widgets/react/Dropdown.tsx index fbcd6a78e..08cdbfed0 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); @@ -75,13 +80,13 @@ export default function Dropdown({ className, buttonClassName, isStatic, childre -
{shown && children} -
+
) } \ No newline at end of file diff --git a/apps/client/src/widgets/react/FormList.tsx b/apps/client/src/widgets/react/FormList.tsx index c12c8a317..f3d6d9427 100644 --- a/apps/client/src/widgets/react/FormList.tsx +++ b/apps/client/src/widgets/react/FormList.tsx @@ -1,9 +1,11 @@ -import { Dropdown as BootstrapDropdown } from "bootstrap"; +import { Dropdown as BootstrapDropdown, Tooltip } from "bootstrap"; import { ComponentChildren } from "preact"; import Icon from "./Icon"; -import { useEffect, useMemo, useRef, type CSSProperties } from "preact/compat"; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from "preact/compat"; import "./FormList.css"; import { CommandNames } from "../../components/app_context"; +import { useStaticTooltip } from "./hooks"; +import { isMobile } from "../../services/utils"; interface FormListOpts { children: ComponentChildren; @@ -89,14 +91,24 @@ interface FormListItemOpts { rtl?: boolean; } -export function FormListItem({ children, icon, value, title, active, badges, disabled, checked, onClick, description, selected, rtl, triggerCommand }: FormListItemOpts) { +const TOOLTIP_CONFIG: Partial = { + placement: "right", + fallbackPlacements: [ "right" ] +} + +export function FormListItem({ className, icon, value, title, active, disabled, checked, onClick, selected, rtl, triggerCommand, description, ...contentProps }: FormListItemOpts) { + const itemRef = useRef(null); + if (checked) { icon = "bx bx-check"; } + useStaticTooltip(itemRef, TOOLTIP_CONFIG); + return ( -   -
- {children} - {badges && badges.map(({ className, text }) => ( - {text} - ))} - {description &&
{description}
} -
-
+ {description ? ( +
+ +
+ ) : ( + + )} + ); } +function FormListContent({ children, badges, description }: Pick) { + return <> + {children} + {badges && badges.map(({ className, text }) => ( + {text} + ))} + {description &&
{description}
} + ; +} + interface FormListHeaderOpts { text: string; } @@ -129,4 +151,30 @@ export function FormListHeader({ text }: FormListHeaderOpts) { export function FormDropdownDivider() { return
; +} + +export function FormDropdownSubmenu({ icon, title, children }: { icon: string, title: ComponentChildren, children: ComponentChildren }) { + const [ openOnMobile, setOpenOnMobile ] = useState(false); + + return ( +
  • + { + e.stopPropagation(); + + if (isMobile()) { + setOpenOnMobile(!openOnMobile); + } + }} + > + {" "} + {title} + + +
      + {children} +
    +
  • + ) } \ No newline at end of file diff --git a/apps/client/src/widgets/react/KeyboardShortcut.tsx b/apps/client/src/widgets/react/KeyboardShortcut.tsx index ca8f6a852..0a76b8093 100644 --- a/apps/client/src/widgets/react/KeyboardShortcut.tsx +++ b/apps/client/src/widgets/react/KeyboardShortcut.tsx @@ -2,11 +2,14 @@ import { ActionKeyboardShortcut, KeyboardActionNames } from "@triliumnext/common import { useEffect, useState } from "preact/hooks"; import keyboard_actions from "../../services/keyboard_actions"; import { joinElements } from "./react_utils"; +import utils from "../../services/utils"; interface KeyboardShortcutProps { actionName: KeyboardActionNames; } +const isMobile = utils.isMobile(); + export default function KeyboardShortcut({ actionName }: KeyboardShortcutProps) { const [ action, setAction ] = useState(); @@ -18,17 +21,14 @@ export default function KeyboardShortcut({ actionName }: KeyboardShortcutProps) return <>; } - return ( - <> - {action.effectiveShortcuts?.map((shortcut) => { + return (!isMobile && + + {joinElements(action.effectiveShortcuts?.map((shortcut) => { const keys = shortcut.split("+"); - return joinElements(keys - .map((key, i) => ( - <> - {key} {i + 1 < keys.length && "+ "} - - ))) - })} - + return joinElements( + keys.map((key, i) => {key}) + , "+"); + }))} + ); } \ No newline at end of file diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index af5378144..523f3ef77 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,9 +503,10 @@ export function useTooltip(elRef: RefObject, config: Partial, config?: Partial) { +export function useStaticTooltip(elRef: RefObject, config?: Partial) { useEffect(() => { - if (!elRef?.current) return; + const hasTooltip = config?.title || elRef.current?.getAttribute("title"); + if (!elRef?.current || !hasTooltip) return; const $el = $(elRef.current); $el.tooltip(config); @@ -514,6 +516,19 @@ export function useStaticTooltip(elRef: RefObject, config?: Partial }, [ elRef, config ]); } +export function useStaticTooltipWithKeyboardShortcut(elRef: RefObject, title: string, actionName: KeyboardActionNames | undefined) { + 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/react/react_utils.tsx b/apps/client/src/widgets/react/react_utils.tsx index 40bb1b9cb..5e436bf14 100644 --- a/apps/client/src/widgets/react/react_utils.tsx +++ b/apps/client/src/widgets/react/react_utils.tsx @@ -41,7 +41,9 @@ export function disposeReactWidget(container: Element) { render(null, container); } -export function joinElements(components: ComponentChild[], separator = ", ") { +export function joinElements(components: ComponentChild[] | undefined, separator = ", ") { + if (!components) return <>; + const joinedComponents: ComponentChild[] = []; for (let i=0; i{joinedComponents}; } 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 ( <> diff --git a/apps/client/src/widgets/scroll_padding.ts b/apps/client/src/widgets/scroll_padding.ts deleted file mode 100644 index 84c96c31b..000000000 --- a/apps/client/src/widgets/scroll_padding.ts +++ /dev/null @@ -1,33 +0,0 @@ -import NoteContextAwareWidget from "./note_context_aware_widget.js"; - -const TPL = /*html*/`
    `; - -export default class ScrollPaddingWidget extends NoteContextAwareWidget { - - private $scrollingContainer!: JQuery; - - isEnabled() { - return super.isEnabled() && ["text", "code"].includes(this.note?.type ?? ""); - } - - doRender() { - this.$widget = $(TPL); - this.contentSized(); - - this.$widget.on("click", () => this.triggerCommand("scrollToEnd", { ntxId: this.ntxId })); - } - - initialRenderCompleteEvent() { - this.$scrollingContainer = this.$widget.closest(".scrolling-container"); - - new ResizeObserver(() => this.refreshHeight()).observe(this.$scrollingContainer[0]); - - this.refreshHeight(); - } - - refreshHeight() { - const containerHeight = this.$scrollingContainer.height(); - - this.$widget.css("height", Math.round((containerHeight ?? 0) / 2)); - } -} diff --git a/apps/client/src/widgets/scroll_padding.tsx b/apps/client/src/widgets/scroll_padding.tsx new file mode 100644 index 000000000..d8452e301 --- /dev/null +++ b/apps/client/src/widgets/scroll_padding.tsx @@ -0,0 +1,42 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { useNoteContext } from "./react/hooks"; + +export default function ScrollPadding() { + const { note, parentComponent, ntxId } = useNoteContext(); + const ref = useRef(null); + const [height, setHeight] = useState(10); + const isEnabled = ["text", "code"].includes(note?.type ?? ""); + + const refreshHeight = () => { + if (!ref.current) return; + const container = ref.current.closest(".scrolling-container") as HTMLElement | null; + if (!container) return; + setHeight(Math.round(container.offsetHeight / 2)); + }; + + useEffect(() => { + if (!isEnabled) return; + + const container = ref.current?.closest(".scrolling-container") as HTMLElement | null; + if (!container) return; + + // Observe container resize + const observer = new ResizeObserver(() => refreshHeight()); + observer.observe(container); + + // Initial resize + refreshHeight(); + + return () => observer.disconnect(); + }, [note]); // re-run when note changes + + return (isEnabled ? +
    parentComponent.triggerCommand("scrollToEnd", { ntxId })} + /> + :
    + ) +} diff --git a/apps/client/src/widgets/search_result.css b/apps/client/src/widgets/search_result.css new file mode 100644 index 000000000..5142bd776 --- /dev/null +++ b/apps/client/src/widgets/search_result.css @@ -0,0 +1,16 @@ +.search-result-widget { + flex-grow: 100000; + flex-shrink: 100000; + min-height: 0; + overflow: auto; + contain: none !important; +} + +.search-result-widget .note-list { + padding: 10px; +} + +.search-no-results, .search-not-executed-yet { + margin: 20px; + padding: 20px !important; +} \ No newline at end of file diff --git a/apps/client/src/widgets/search_result.ts b/apps/client/src/widgets/search_result.ts deleted file mode 100644 index 6fa69ae13..000000000 --- a/apps/client/src/widgets/search_result.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { t } from "../services/i18n.js"; -import NoteContextAwareWidget from "./note_context_aware_widget.js"; -import NoteListRenderer from "../services/note_list_renderer.js"; -import type FNote from "../entities/fnote.js"; -import type { EventData } from "../components/app_context.js"; - -const TPL = /*html*/` -
    - - -
    - ${t("search_result.no_notes_found")} -
    - -
    - ${t("search_result.search_not_executed")} -
    - -
    -
    -
    `; - -export default class SearchResultWidget extends NoteContextAwareWidget { - - private $content!: JQuery; - private $noResults!: JQuery; - private $notExecutedYet!: JQuery; - - isEnabled() { - return super.isEnabled() && this.note?.type === "search"; - } - - doRender() { - this.$widget = $(TPL); - this.contentSized(); - this.$content = this.$widget.find(".search-result-widget-content"); - this.$noResults = this.$widget.find(".search-no-results"); - this.$notExecutedYet = this.$widget.find(".search-not-executed-yet"); - } - - async refreshWithNote(note: FNote) { - const noResults = note.getChildNoteIds().length === 0 && !!note.searchResultsLoaded; - - this.$content.empty(); - this.$noResults.toggle(noResults); - this.$notExecutedYet.toggle(!note.searchResultsLoaded); - - if (noResults || !note.searchResultsLoaded) { - return; - } - - const noteListRenderer = new NoteListRenderer({ - $parent: this.$content, - parentNote: note, - showNotePath: true - }); - await noteListRenderer.renderList(); - } - - searchRefreshedEvent({ ntxId }: EventData<"searchRefreshed">) { - if (!this.isNoteContext(ntxId)) { - return; - } - - this.refresh(); - } - - notesReloadedEvent({ noteIds }: EventData<"notesReloaded">) { - if (this.noteId && noteIds.includes(this.noteId)) { - this.refresh(); - } - } -} diff --git a/apps/client/src/widgets/search_result.tsx b/apps/client/src/widgets/search_result.tsx new file mode 100644 index 000000000..88ca424fa --- /dev/null +++ b/apps/client/src/widgets/search_result.tsx @@ -0,0 +1,65 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { t } from "../services/i18n"; +import Alert from "./react/Alert"; +import { useNoteContext, useNoteProperty, useTriliumEvent } from "./react/hooks"; +import "./search_result.css"; +import NoteListRenderer from "../services/note_list_renderer"; + +enum SearchResultState { + NO_RESULTS, + NOT_EXECUTED, + GOT_RESULTS +} + +export default function SearchResult() { + const { note, ntxId } = useNoteContext(); + const [ state, setState ] = useState(); + const searchContainerRef = useRef(null); + + function refresh() { + searchContainerRef.current?.replaceChildren(); + + if (note?.type !== "search") { + setState(undefined); + } else if (!note?.searchResultsLoaded) { + setState(SearchResultState.NOT_EXECUTED); + } else if (note.getChildNoteIds().length === 0) { + setState(SearchResultState.NO_RESULTS); + } else if (searchContainerRef.current) { + setState(SearchResultState.GOT_RESULTS); + + const noteListRenderer = new NoteListRenderer({ + $parent: $(searchContainerRef.current), + parentNote: note, + showNotePath: true + }); + noteListRenderer.renderList(); + } + } + + useEffect(() => refresh(), [ note ]); + useTriliumEvent("searchRefreshed", ({ ntxId: eventNtxId }) => { + if (eventNtxId === ntxId) { + refresh(); + } + }); + useTriliumEvent("notesReloaded", ({ noteIds }) => { + if (note?.noteId && noteIds.includes(note.noteId)) { + refresh(); + } + }); + + return ( +
    + {state === SearchResultState.NOT_EXECUTED && ( + {t("search_result.search_not_executed")} + )} + + {state === SearchResultState.NO_RESULTS && ( + {t("search_result.no_notes_found")} + )} + +
    +
    + ); +} \ No newline at end of file diff --git a/apps/client/src/widgets/sql_result.css b/apps/client/src/widgets/sql_result.css new file mode 100644 index 000000000..63b5621ed --- /dev/null +++ b/apps/client/src/widgets/sql_result.css @@ -0,0 +1,7 @@ +.sql-result-widget { + padding: 15px; +} + +.sql-console-result-container td { + white-space: preserve; +} \ No newline at end of file diff --git a/apps/client/src/widgets/sql_result.ts b/apps/client/src/widgets/sql_result.ts deleted file mode 100644 index f9f579315..000000000 --- a/apps/client/src/widgets/sql_result.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { EventData } from "../components/app_context.js"; -import { t } from "../services/i18n.js"; -import NoteContextAwareWidget from "./note_context_aware_widget.js"; - -const TPL = /*html*/` -
    - - - - -
    -
    `; - -export default class SqlResultWidget extends NoteContextAwareWidget { - - private $resultContainer!: JQuery; - private $noRowsAlert!: JQuery; - - isEnabled() { - return this.note && this.note.mime === "text/x-sqlite;schema=trilium" && super.isEnabled(); - } - - doRender() { - this.$widget = $(TPL); - - this.$resultContainer = this.$widget.find(".sql-console-result-container"); - this.$noRowsAlert = this.$widget.find(".sql-query-no-rows"); - } - - async sqlQueryResultsEvent({ ntxId, results }: EventData<"sqlQueryResults">) { - if (!this.isNoteContext(ntxId)) { - return; - } - - this.$noRowsAlert.toggle(results.length === 1 && results[0].length === 0); - this.$resultContainer.toggle(results.length > 1 || results[0].length > 0); - - this.$resultContainer.empty(); - - for (const rows of results) { - if (typeof rows === "object" && !Array.isArray(rows)) { - // inserts, updates - this.$resultContainer - .empty() - .show() - .append($("
    ").text(JSON.stringify(rows, null, "\t")));
    -
    -                continue;
    -            }
    -
    -            if (!rows.length) {
    -                continue;
    -            }
    -
    -            const $table = $('');
    -            this.$resultContainer.append($table);
    -
    -            const result = rows[0];
    -            const $row = $("");
    -
    -            for (const key in result) {
    -                $row.append($("");
    -
    -                for (const key in result) {
    -                    $row.append($("
    ").text(key)); - } - - $table.append($row); - - for (const result of rows) { - const $row = $("
    ").text(result[key])); - } - - $table.append($row); - } - } - } -} diff --git a/apps/client/src/widgets/sql_result.tsx b/apps/client/src/widgets/sql_result.tsx new file mode 100644 index 000000000..760651774 --- /dev/null +++ b/apps/client/src/widgets/sql_result.tsx @@ -0,0 +1,62 @@ +import { SqlExecuteResults } from "@triliumnext/commons"; +import { useNoteContext, useTriliumEvent } from "./react/hooks"; +import "./sql_result.css"; +import { useState } from "preact/hooks"; +import Alert from "./react/Alert"; +import { t } from "../services/i18n"; + +export default function SqlResults() { + const { note, ntxId } = useNoteContext(); + const [ results, setResults ] = useState(); + + useTriliumEvent("sqlQueryResults", ({ ntxId: eventNtxId, results }) => { + if (eventNtxId !== ntxId) return; + setResults(results); + }) + + return ( +
    + {note?.mime === "text/x-sqlite;schema=trilium" && ( + results?.length === 1 && Array.isArray(results[0]) && results[0].length === 0 ? ( + + {t("sql_result.no_rows")} + + ) : ( +
    + {results?.map(rows => { + // inserts, updates + if (typeof rows === "object" && !Array.isArray(rows)) { + return
    {JSON.stringify(rows, null, "\t")}
    + } + + // selects + return + })} +
    + ) + )} +
    + ) +} + +function SqlResultTable({ rows }: { rows: object[] }) { + if (!rows.length) return; + + return ( + + + + {Object.keys(rows[0]).map(key => )} + + + + + {rows.map(row => ( + + {Object.values(row).map(cell => )} + + ))} + +
    {key}
    {cell}
    + ) +} \ No newline at end of file diff --git a/apps/client/src/widgets/sql_table_schemas.css b/apps/client/src/widgets/sql_table_schemas.css new file mode 100644 index 000000000..d13a71dfd --- /dev/null +++ b/apps/client/src/widgets/sql_table_schemas.css @@ -0,0 +1,43 @@ +.sql-table-schemas-widget { + padding: 12px; + padding-right: 10%; + contain: none !important; +} + +.sql-table-schemas > .dropdown { + display: inline-block !important; +} + +.sql-table-schemas button.btn { + padding: 0.25rem 0.4rem; + font-size: 0.875rem; + line-height: 0.5; + border: 1px solid var(--button-border-color); + border-radius: var(--button-border-radius); + background: var(--button-background-color); + color: var(--button-text-color); + cursor: pointer; +} + +.sql-console-result-container { + width: 100%; + font-size: smaller; + margin-top: 10px; + flex-grow: 1; + overflow: auto; + min-height: 0; +} + +.table-schema td { + padding: 5px; +} + +.dropdown .table-schema { + font-family: var(--monospace-font-family); + font-size: .85em; +} + +/* Data type */ +.dropdown .table-schema td:nth-child(2) { + color: var(--muted-text-color); +} \ No newline at end of file diff --git a/apps/client/src/widgets/sql_table_schemas.ts b/apps/client/src/widgets/sql_table_schemas.ts deleted file mode 100644 index 5a15881c4..000000000 --- a/apps/client/src/widgets/sql_table_schemas.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { t } from "../services/i18n.js"; -import NoteContextAwareWidget from "./note_context_aware_widget.js"; -import server from "../services/server.js"; -import type FNote from "../entities/fnote.js"; - -const TPL = /*html*/` -
    - - - ${t("sql_table_schemas.tables")}: - -
    `; - -interface SchemaResponse { - name: string; - columns: { - name: string; - type: string; - }[]; -} - -export default class SqlTableSchemasWidget extends NoteContextAwareWidget { - - private tableSchemasShown?: boolean; - private $sqlConsoleTableSchemas!: JQuery; - - isEnabled() { - return this.note && this.note.mime === "text/x-sqlite;schema=trilium" && super.isEnabled(); - } - - doRender() { - this.$widget = $(TPL); - this.contentSized(); - - this.$sqlConsoleTableSchemas = this.$widget.find(".sql-table-schemas"); - } - - async refreshWithNote(note: FNote) { - if (this.tableSchemasShown) { - return; - } - - this.tableSchemasShown = true; - - const tableSchema = await server.get("sql/schema"); - - for (const table of tableSchema) { - const $tableLink = $('