diff --git a/apps/client/index.html b/apps/client/index.html index e1db35332..12f653666 100644 --- a/apps/client/index.html +++ b/apps/client/index.html @@ -13,6 +13,7 @@ +
diff --git a/apps/client/src/layouts/mobile_layout.tsx b/apps/client/src/layouts/mobile_layout.tsx index da66ffa13..dbe88ade8 100644 --- a/apps/client/src/layouts/mobile_layout.tsx +++ b/apps/client/src/layouts/mobile_layout.tsx @@ -179,7 +179,6 @@ export default class MobileLayout { new FlexContainer("column") .contentSized() .id("mobile-bottom-bar") - .child(new TabRowWidget().css("height", "40px")) .child(new FlexContainer("row") .class("horizontal") .css("height", "53px") diff --git a/apps/client/src/menus/context_menu.ts b/apps/client/src/menus/context_menu.ts index 6bd9de9e4..986ea94c4 100644 --- a/apps/client/src/menus/context_menu.ts +++ b/apps/client/src/menus/context_menu.ts @@ -1,8 +1,9 @@ import { KeyboardActionNames } from "@triliumnext/commons"; +import { h, JSX, render } from "preact"; + import keyboardActionService, { getActionSync } from "../services/keyboard_actions.js"; import note_tooltip from "../services/note_tooltip.js"; import utils from "../services/utils.js"; -import { h, JSX, render } from "preact"; export interface ContextMenuOptions { x: number; @@ -62,17 +63,17 @@ export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEve class ContextMenu { private $widget: JQuery; - private $cover: JQuery; + private $cover?: JQuery; private options?: ContextMenuOptions; private isMobile: boolean; constructor() { this.$widget = $("#context-menu-container"); - this.$cover = $("#context-menu-cover"); this.$widget.addClass("dropend"); this.isMobile = utils.isMobile(); if (this.isMobile) { + this.$cover = $("#context-menu-cover"); this.$cover.on("click", () => this.hide()); } else { $(document).on("click", (e) => this.hide()); @@ -91,7 +92,7 @@ class ContextMenu { } this.$widget.toggleClass("mobile-bottom-menu", !this.options.forcePositionOnMobile); - this.$cover.addClass("show"); + this.$cover?.addClass("show"); $("body").addClass("context-menu-shown"); this.$widget.empty(); @@ -140,16 +141,14 @@ class ContextMenu { } else { left = this.options.x - contextMenuWidth + CONTEXT_MENU_OFFSET; } + } else if (contextMenuWidth && this.options.x + contextMenuWidth - CONTEXT_MENU_OFFSET > clientWidth - CONTEXT_MENU_PADDING) { + // Overflow: right + left = clientWidth - contextMenuWidth - CONTEXT_MENU_PADDING; + } else if (this.options.x - CONTEXT_MENU_OFFSET < CONTEXT_MENU_PADDING) { + // Overflow: left + left = CONTEXT_MENU_PADDING; } else { - if (contextMenuWidth && this.options.x + contextMenuWidth - CONTEXT_MENU_OFFSET > clientWidth - CONTEXT_MENU_PADDING) { - // Overflow: right - left = clientWidth - contextMenuWidth - CONTEXT_MENU_PADDING; - } else if (this.options.x - CONTEXT_MENU_OFFSET < CONTEXT_MENU_PADDING) { - // Overflow: left - left = CONTEXT_MENU_PADDING; - } else { - left = this.options.x - CONTEXT_MENU_OFFSET; - } + left = this.options.x - CONTEXT_MENU_OFFSET; } this.$widget @@ -261,7 +260,7 @@ class ContextMenu { .append(item.title); if ("badges" in item && item.badges) { - for (let badge of item.badges) { + for (const badge of item.badges) { const badgeElement = $(``).text(badge.title); if (badge.className) { @@ -352,7 +351,7 @@ class ContextMenu { async hide() { this.options?.onHide?.(); this.$widget.removeClass("show"); - this.$cover.removeClass("show"); + this.$cover?.removeClass("show"); $("body").removeClass("context-menu-shown"); this.$widget.hide(); } diff --git a/apps/client/src/services/css_class_manager.ts b/apps/client/src/services/css_class_manager.ts index de1c98b87..06ff7b704 100644 --- a/apps/client/src/services/css_class_manager.ts +++ b/apps/client/src/services/css_class_manager.ts @@ -49,7 +49,7 @@ function createClassForColor(colorString: string | null) { return clsx("use-note-color", className, colorsWithHue.has(className) && "with-hue"); } -function parseColor(color: string) { +export function parseColor(color: string) { try { return Color(color.toLowerCase()); } catch (ex) { @@ -77,7 +77,7 @@ function adjustColorLightness(color: ColorInstance, lightThemeMaxLightness: numb } /** Returns the hue of the specified color, or undefined if the color is grayscale. */ -function getHue(color: ColorInstance) { +export function getHue(color: ColorInstance) { const hslColor = color.hsl(); if (hslColor.saturationl() > 0) { return hslColor.hue(); diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index 17e431e54..69632dd29 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -224,10 +224,6 @@ body.mobile .modal .modal-dialog { width: 100%; } -body.mobile .modal .modal-content { - border-radius: var(--bs-modal-border-radius) var(--bs-modal-border-radius) 0 0; -} - .component { contain: size; } @@ -1255,7 +1251,7 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href inset-inline-start: 0; inset-inline-end: 0; bottom: 0; - z-index: 1000; + z-index: 2500; background: rgba(0, 0, 0, 0.1); } @@ -1614,6 +1610,7 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu { body.mobile .modal-content { overflow-y: auto; + border-radius: var(--bs-modal-border-radius) var(--bs-modal-border-radius) 0 0; } body.mobile .modal-footer { @@ -1669,6 +1666,15 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu { #detail-container { background: var(--main-background-color); } + + .modal-dialog { + margin: var(--bs-modal-margin); + max-width: 80%; + } + + .modal-content { + height: 100%; + } } @media (max-width: 991px) { diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index d7d91b244..6bd13a334 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2271,5 +2271,10 @@ }, "platform_indicator": { "available_on": "Available on {{platform}}" + }, + "mobile_tab_switcher": { + "title_one": "{{count}} tab", + "title_other": "{{count}} tabs", + "more_options": "More options" } } diff --git a/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx index 61f79cc8d..61c7193a6 100644 --- a/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx @@ -155,7 +155,7 @@ function NoteAttributes({ note }: { note: FNote }) { return ; } -function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes }: { +export function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes }: { note: FNote; trim?: boolean; noChildrenList?: boolean; diff --git a/apps/client/src/widgets/launch_bar/LauncherContainer.tsx b/apps/client/src/widgets/launch_bar/LauncherContainer.tsx index 3450a4c01..d202feaf3 100644 --- a/apps/client/src/widgets/launch_bar/LauncherContainer.tsx +++ b/apps/client/src/widgets/launch_bar/LauncherContainer.tsx @@ -3,6 +3,7 @@ import { useCallback, useLayoutEffect, useState } from "preact/hooks"; import FNote from "../../entities/fnote"; import froca from "../../services/froca"; import { isDesktop, isMobile } from "../../services/utils"; +import TabSwitcher from "../mobile_widgets/TabSwitcher"; import { useTriliumEvent } from "../react/hooks"; import { onWheelHorizontalScroll } from "../widget_utils"; import BookmarkButtons from "./BookmarkButtons"; @@ -97,6 +98,8 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) { return ; case "aiChatLauncher": return ; + case "mobileTabSwitcher": + return ; default: throw new Error(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`); } diff --git a/apps/client/src/widgets/launch_bar/launch_bar_widgets.tsx b/apps/client/src/widgets/launch_bar/launch_bar_widgets.tsx index 0cb7c9906..bb4a053c8 100644 --- a/apps/client/src/widgets/launch_bar/launch_bar_widgets.tsx +++ b/apps/client/src/widgets/launch_bar/launch_bar_widgets.tsx @@ -1,3 +1,4 @@ +import clsx from "clsx"; import { createContext } from "preact"; import { useContext } from "preact/hooks"; @@ -18,12 +19,12 @@ export interface LauncherNoteProps { launcherNote: FNote; } -export function LaunchBarActionButton(props: Omit) { +export function LaunchBarActionButton({ className, ...props }: Omit) { const { isHorizontalLayout } = useContext(LaunchBarContext); return ( .tn-icon { + margin-inline-end: 0.4em; + } + + .title { + overflow: hidden; + text-overflow: ellipsis; + text-wrap: nowrap; + font-size: 0.9em; + flex-grow: 1; + } + + .icon-action { + flex-shrink: 0; + } + } + + .tab-preview { + flex-grow: 1; + height: 100%; + overflow: hidden; + font-size: 0.5em; + user-select: none; + pointer-events: none; + + &.type-text { + padding: 10px; + } + + &.type-book, + &.type-contentWidget, + &.type-search, + &.type-empty { + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25em; + color: var(--muted-text-color); + } + + .preview-placeholder { + font-size: 500%; + } + + p { margin-bottom: 0.2em;} + h2 { font-size: 1.20em; } + h3 { font-size: 1.15em; } + h4 { font-size: 1.10em; } + h5 { font-size: 1.05em} + h6 { font-size: 1em; } + } + + &.with-split { + .preview-placeholder { + font-size: 250%; + } + } + } + } + + .modal-footer { + .tn-link { + color: var(--main-text-color); + width: 40%; + text-align: center; + text-decoration: none; + } + } +} diff --git a/apps/client/src/widgets/mobile_widgets/TabSwitcher.tsx b/apps/client/src/widgets/mobile_widgets/TabSwitcher.tsx new file mode 100644 index 000000000..6ee84f046 --- /dev/null +++ b/apps/client/src/widgets/mobile_widgets/TabSwitcher.tsx @@ -0,0 +1,240 @@ +import "./TabSwitcher.css"; + +import clsx from "clsx"; +import { createPortal, Fragment } from "preact/compat"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; + +import appContext, { CommandNames } from "../../components/app_context"; +import NoteContext from "../../components/note_context"; +import FNote from "../../entities/fnote"; +import contextMenu from "../../menus/context_menu"; +import { getHue, parseColor } from "../../services/css_class_manager"; +import froca from "../../services/froca"; +import { t } from "../../services/i18n"; +import { NoteContent } from "../collections/legacy/ListOrGridView"; +import { LaunchBarActionButton } 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"; +import Icon from "../react/Icon"; +import LinkButton from "../react/LinkButton"; +import Modal from "../react/Modal"; + +export default function TabSwitcher() { + const [ shown, setShown ] = useState(false); + const mainNoteContexts = useMainNoteContexts(); + + return ( + <> + setShown(true)} + data-tab-count={mainNoteContexts.length > 99 ? "∞" : mainNoteContexts.length} + /> + {createPortal(, document.body)} + + ); +} + +function TabBarModal({ mainNoteContexts, shown, setShown }: { + mainNoteContexts: NoteContext[]; + shown: boolean; + setShown: (newValue: boolean) => void; +}) { + const [ fullyShown, setFullyShown ] = useState(false); + const selectTab = useCallback((noteContextToActivate: NoteContext) => { + appContext.tabManager.activateNoteContext(noteContextToActivate.ntxId); + setShown(false); + }, [ setShown ]); + + return ( + setFullyShown(true)} + customTitleBarButtons={[ + { + iconClassName: "bx bx-dots-vertical-rounded", + title: t("mobile_tab_switcher.more_options"), + onClick(e) { + contextMenu.show({ + x: e.pageX, + y: e.pageY, + items: [ + { title: t("tab_row.new_tab"), command: "openNewTab", uiIcon: "bx bx-plus" }, + { title: t("tab_row.reopen_last_tab"), command: "reopenLastTab", uiIcon: "bx bx-undo", enabled: appContext.tabManager.recentlyClosedTabs.length !== 0 }, + { kind: "separator" }, + { title: t("tab_row.close_all_tabs"), command: "closeAllTabs", uiIcon: "bx bx-trash destructive-action-icon" }, + ], + selectMenuItemHandler: ({ command }) => { + if (command) { + appContext.triggerCommand(command); + } + } + }); + }, + } + ]} + footer={<> + { + appContext.triggerCommand("openNewTab"); + setShown(false); + }} + /> + } + scrollable + onHidden={() => { + setShown(false); + setFullyShown(false); + }} + > + + + ); +} + +function TabBarModelContent({ mainNoteContexts, selectTab, shown }: { + mainNoteContexts: NoteContext[]; + shown: boolean; + selectTab: (noteContextToActivate: NoteContext) => void; +}) { + const activeNoteContext = useActiveNoteContext(); + const tabRefs = useRef>({}); + + // Scroll to active tab. + useEffect(() => { + if (!shown || !activeNoteContext?.ntxId) return; + const correspondingEl = tabRefs.current[activeNoteContext.ntxId]; + requestAnimationFrame(() => { + correspondingEl?.scrollIntoView(); + }); + }, [ activeNoteContext, shown ]); + + return ( +
+ {mainNoteContexts.map((noteContext) => ( + (tabRefs.current[noteContext.ntxId ?? ""] = el)} + /> + ))} +
+ ); +} + +function Tab({ noteContext, containerRef, selectTab, activeNtxId }: { + containerRef: (el: HTMLDivElement | null) => void; + noteContext: NoteContext; + selectTab: (noteContextToActivate: NoteContext) => void; + activeNtxId: string | null | undefined; +}) { + const { note } = noteContext; + const iconClass = useNoteIcon(note); + const colorClass = note?.getColorClass() || ''; + const workspaceTabBackgroundColorHue = getWorkspaceTabBackgroundColorHue(noteContext); + const subContexts = noteContext.getSubContexts(); + + return ( +
1 + })} + onClick={() => selectTab(noteContext)} + style={{ + "--bg-hue": workspaceTabBackgroundColorHue + }} + > + {subContexts.map(subContext => ( + +
+ {subContext.note && } + {subContext.note?.title ?? t("tab_row.new_tab")} + {subContext.isMainContext() && { + // We are closing a tab, so we need to prevent propagation for click (activate tab). + e.stopPropagation(); + appContext.tabManager.removeNoteContext(subContext.ntxId); + }} + />} +
+
+ +
+
+ ))} +
+ ); +} + +function TabPreviewContent({ note }: { + note: FNote | null +}) { + if (!note) { + return ; + } + + if (note.type === "book") { + return ; + } + + return ( + + ); +} + +function PreviewPlaceholder({ icon}: { + icon: string; +}) { + return ( +
+ +
+ ); +} + +function getWorkspaceTabBackgroundColorHue(noteContext: NoteContext) { + if (!noteContext.hoistedNoteId) return; + const hoistedNote = froca.getNoteFromCache(noteContext.hoistedNoteId); + if (!hoistedNote) return; + + const workspaceTabBackgroundColor = hoistedNote.getWorkspaceTabBackgroundColor(); + if (!workspaceTabBackgroundColor) return; + + try { + const parsedColor = parseColor(workspaceTabBackgroundColor); + if (!parsedColor) return; + return getHue(parsedColor); + } catch (e) { + // Colors are non-critical, simply ignore. + console.warn(e); + } +} + +function useMainNoteContexts() { + const [ noteContexts, setNoteContexts ] = useState(appContext.tabManager.getMainNoteContexts()); + + useTriliumEvents([ "newNoteContextCreated", "noteContextRemoved" ] , () => { + setNoteContexts(appContext.tabManager.getMainNoteContexts()); + }); + + return noteContexts; +} diff --git a/apps/client/src/widgets/note_bars/CollectionProperties.tsx b/apps/client/src/widgets/note_bars/CollectionProperties.tsx index d466e813c..5dba675e6 100644 --- a/apps/client/src/widgets/note_bars/CollectionProperties.tsx +++ b/apps/client/src/widgets/note_bars/CollectionProperties.tsx @@ -6,10 +6,7 @@ import { useContext, useRef } from "preact/hooks"; import { Fragment } from "preact/jsx-runtime"; import FNote from "../../entities/fnote"; -import { getHelpUrlForNote } from "../../services/in_app_help"; -import { openInAppHelpFromUrl } from "../../services/utils"; import { ViewTypeOptions } from "../collections/interface"; -import ActionButton from "../react/ActionButton"; import Dropdown from "../react/Dropdown"; import { FormDropdownDivider, FormDropdownSubmenu, FormListItem, FormListToggleableItem } from "../react/FormList"; import FormTextBox from "../react/FormTextBox"; @@ -19,7 +16,7 @@ import { ParentComponent } from "../react/react_utils"; import { bookPropertiesConfig, BookProperty, ButtonProperty, CheckBoxProperty, ComboBoxItem, ComboBoxProperty, NumberProperty, SplitButtonProperty } from "../ribbon/collection-properties-config"; import { useViewType, VIEW_TYPE_MAPPINGS } from "../ribbon/CollectionPropertiesTab"; -const ICON_MAPPINGS: Record = { +export const ICON_MAPPINGS: Record = { grid: "bx bxs-grid", list: "bx bx-list-ul", calendar: "bx bx-calendar", diff --git a/apps/client/src/widgets/note_icon.tsx b/apps/client/src/widgets/note_icon.tsx index 7bbbd3e34..51204b015 100644 --- a/apps/client/src/widgets/note_icon.tsx +++ b/apps/client/src/widgets/note_icon.tsx @@ -6,6 +6,7 @@ import clsx from "clsx"; import { t } from "i18next"; import { CSSProperties, RefObject } from "preact"; import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import type React from "react"; import { CellComponentProps, Grid } from "react-window"; import FNote from "../entities/fnote"; @@ -153,10 +154,10 @@ function NoteIconList({ note, dropdownRef }: { function IconItemCell({ rowIndex, columnIndex, style, filteredIcons }: CellComponentProps<{ filteredIcons: IconWithName[]; -}>): React.JSX.Element { +}>) { const iconIndex = rowIndex * 12 + columnIndex; const iconData = filteredIcons[iconIndex] as IconWithName | undefined; - if (!iconData) return <>; + if (!iconData) return <> as React.ReactElement; const { id, terms, iconPack } = iconData; return ( @@ -166,7 +167,7 @@ function IconItemCell({ rowIndex, columnIndex, style, filteredIcons }: CellCompo title={t("note_icon.icon_tooltip", { name: terms?.[0] ?? id, iconPack })} style={style as CSSProperties} /> - ); + ) as React.ReactElement; } function IconFilterContent({ filterByPrefix, setFilterByPrefix }: { diff --git a/apps/client/src/widgets/react/ActionButton.tsx b/apps/client/src/widgets/react/ActionButton.tsx index ba5430f38..feb5972ef 100644 --- a/apps/client/src/widgets/react/ActionButton.tsx +++ b/apps/client/src/widgets/react/ActionButton.tsx @@ -1,10 +1,11 @@ -import { useEffect, useRef, useState } from "preact/hooks"; -import { CommandNames } from "../../components/app_context"; -import { useStaticTooltip } from "./hooks"; -import keyboard_actions from "../../services/keyboard_actions"; import { HTMLAttributes } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; -export interface ActionButtonProps extends Pick, "onClick" | "onAuxClick" | "onContextMenu"> { +import { CommandNames } from "../../components/app_context"; +import keyboard_actions from "../../services/keyboard_actions"; +import { useStaticTooltip } from "./hooks"; + +export interface ActionButtonProps extends Pick, "onClick" | "onAuxClick" | "onContextMenu" | "style"> { text: string; titlePosition?: "top" | "right" | "bottom" | "left"; icon: string; diff --git a/apps/client/src/widgets/react/Modal.tsx b/apps/client/src/widgets/react/Modal.tsx index d444f9339..e7a7c721f 100644 --- a/apps/client/src/widgets/react/Modal.tsx +++ b/apps/client/src/widgets/react/Modal.tsx @@ -1,17 +1,17 @@ -import clsx from "clsx"; -import { useEffect, useRef, useMemo } from "preact/hooks"; -import { t } from "../../services/i18n"; -import { ComponentChildren } from "preact"; -import type { CSSProperties, RefObject } from "preact/compat"; -import { openDialog } from "../../services/dialog"; import { Modal as BootstrapModal } from "bootstrap"; +import clsx from "clsx"; +import { ComponentChildren, CSSProperties, RefObject } from "preact"; import { memo } from "preact/compat"; +import { useEffect, useMemo, useRef } from "preact/hooks"; + +import { openDialog } from "../../services/dialog"; +import { t } from "../../services/i18n"; import { useSyncedRef } from "./hooks"; interface CustomTitleBarButton { title: string; iconClassName: string; - onClick: () => void; + onClick: (e: MouseEvent) => void; } export interface ModalProps { @@ -80,7 +80,7 @@ export interface ModalProps { noFocus?: boolean; } -export default function Modal({ children, className, size, title, customTitleBarButtons: titleBarButtons, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden: onHidden, modalRef: externalModalRef, formRef, bodyStyle, show, stackable, keepInDom, noFocus }: ModalProps) { +export default function Modal({ children, className, size, title, customTitleBarButtons: titleBarButtons, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden, modalRef: externalModalRef, formRef, bodyStyle, show, stackable, keepInDom, noFocus }: ModalProps) { const modalRef = useSyncedRef(externalModalRef); const modalInstanceRef = useRef(); const elementToFocus = useRef(); @@ -116,7 +116,7 @@ export default function Modal({ children, className, size, title, customTitleBar focus: !noFocus }).then(($widget) => { modalInstanceRef.current = BootstrapModal.getOrCreateInstance($widget[0]); - }) + }); } else { modalInstanceRef.current?.hide(); } @@ -159,13 +159,12 @@ export default function Modal({ children, className, size, title, customTitleBar {titleBarButtons?.filter((b) => b !== null).map((titleBarButton) => ( + className={clsx("custom-title-bar-button bx", titleBarButton.iconClassName)} + title={titleBarButton.title} + onClick={titleBarButton.onClick} /> ))} - +