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} />
))}
-
+
diff --git a/apps/client/src/widgets/type_widgets/options/other.tsx b/apps/client/src/widgets/type_widgets/options/other.tsx
index 6ac92b420..e6813f8d2 100644
--- a/apps/client/src/widgets/type_widgets/options/other.tsx
+++ b/apps/client/src/widgets/type_widgets/options/other.tsx
@@ -1,20 +1,22 @@
+import { SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons";
+import { useMemo } from "preact/hooks";
+import type React from "react";
import { Trans } from "react-i18next";
+
import { t } from "../../../services/i18n";
+import search from "../../../services/search";
import server from "../../../services/server";
import toast from "../../../services/toast";
+import { isElectron } from "../../../services/utils";
import Button from "../../react/Button";
-import FormText from "../../react/FormText";
-import OptionsSection from "./components/OptionsSection";
-import TimeSelector from "./components/TimeSelector";
-import { useMemo } from "preact/hooks";
-import { useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks";
-import { SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons";
import FormCheckbox from "../../react/FormCheckbox";
import FormGroup from "../../react/FormGroup";
-import search from "../../../services/search";
-import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
import FormSelect from "../../react/FormSelect";
-import { isElectron } from "../../../services/utils";
+import FormText from "../../react/FormText";
+import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
+import { useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks";
+import OptionsSection from "./components/OptionsSection";
+import TimeSelector from "./components/TimeSelector";
export default function OtherSettings() {
return (
@@ -31,7 +33,7 @@ export default function OtherSettings() {
>
- )
+ );
}
function SearchEngineSettings() {
@@ -82,7 +84,7 @@ function SearchEngineSettings() {
/>
- )
+ );
}
function TrayOptionsSettings() {
@@ -97,7 +99,7 @@ function TrayOptionsSettings() {
onChange={trayEnabled => setDisableTray(!trayEnabled)}
/>
- )
+ );
}
function NoteErasureTimeout() {
@@ -105,13 +107,13 @@ function NoteErasureTimeout() {
{t("note_erasure_timeout.note_erasure_description")}
-
{t("note_erasure_timeout.manual_erasing_description")}
-
+
- )
+ );
}
function AttachmentErasureTimeout() {
@@ -145,7 +147,7 @@ function AttachmentErasureTimeout() {
}}
/>
- )
+ );
}
function RevisionSnapshotInterval() {
@@ -165,7 +167,7 @@ function RevisionSnapshotInterval() {
/>
- )
+ );
}
function RevisionSnapshotLimit() {
@@ -176,7 +178,7 @@ function RevisionSnapshotLimit() {
{t("revisions_snapshot_limit.note_revisions_snapshot_limit_description")}
-
- )
+ );
}
function HtmlImportTags() {
@@ -236,7 +238,7 @@ function HtmlImportTags() {
onClick={() => setAllowedHtmlTags(SANITIZER_DEFAULT_ALLOWED_TAGS)}
/>
- )
+ );
}
function ShareSettings() {
@@ -246,8 +248,8 @@ function ShareSettings() {
return (
- {
if (value) {
@@ -264,17 +266,17 @@ function ShareSettings() {
}
setRedirectBareDomain(value);
}}
- />
+ />
-
- )
+ );
}
function NetworkSettings() {
@@ -288,5 +290,5 @@ function NetworkSettings() {
currentValue={checkForUpdates} onChange={setCheckForUpdates}
/>
- )
-}
\ No newline at end of file
+ );
+}
diff --git a/apps/client/src/widgets/type_widgets/options/text_notes.tsx b/apps/client/src/widgets/type_widgets/options/text_notes.tsx
index 7b6e15dda..e2ff88b44 100644
--- a/apps/client/src/widgets/type_widgets/options/text_notes.tsx
+++ b/apps/client/src/widgets/type_widgets/options/text_notes.tsx
@@ -2,6 +2,7 @@ import { normalizeMimeTypeForCKEditor, type OptionNames } from "@triliumnext/com
import { Themes } from "@triliumnext/highlightjs";
import type { CSSProperties } from "preact/compat";
import { useEffect, useMemo, useState } from "preact/hooks";
+import type React from "react";
import { Trans } from "react-i18next";
import { isExperimentalFeatureEnabled } from "../../../services/experimental_features";
diff --git a/apps/server-e2e/src/layout/tab_bar.spec.ts b/apps/server-e2e/src/layout/tab_bar.spec.ts
index 791064966..c408114a0 100644
--- a/apps/server-e2e/src/layout/tab_bar.spec.ts
+++ b/apps/server-e2e/src/layout/tab_bar.spec.ts
@@ -17,17 +17,17 @@ test("Can drag tabs around", async ({ page, context }) => {
await app.addNewTab();
await app.addNewTab();
- let tab = app.getTab(0);
+ let tab = await app.getTab(0);
// Drag the first tab at the end
- await tab.dragTo(app.getTab(2), { targetPosition: { x: 50, y: 0 } });
+ await tab.dragTo(await app.getTab(2), { targetPosition: { x: 50, y: 0 } });
- tab = app.getTab(2);
+ tab = await app.getTab(2);
await expect(tab).toContainText(NOTE_TITLE);
// Drag the tab to the left
- await tab.dragTo(app.getTab(0), { targetPosition: { x: 50, y: 0 } });
- await expect(app.getTab(0)).toContainText(NOTE_TITLE);
+ await tab.dragTo(await app.getTab(0), { targetPosition: { x: 50, y: 0 } });
+ await expect(await app.getTab(0)).toContainText(NOTE_TITLE);
});
test("Can drag tab to new window", async ({ page, context }) => {
@@ -36,7 +36,7 @@ test("Can drag tab to new window", async ({ page, context }) => {
await app.closeAllTabs();
await app.clickNoteOnNoteTreeByTitle(NOTE_TITLE);
- const tab = app.getTab(0);
+ const tab = await app.getTab(0);
await expect(tab).toContainText(NOTE_TITLE);
const popupPromise = page.waitForEvent("popup");
@@ -75,14 +75,14 @@ test("Tabs are restored in right order", async ({ page, context }) => {
await expect(app.getActiveTab()).toContainText("Mermaid");
// Select the mid one.
- await app.getTab(1).click();
+ await (await app.getTab(1)).click();
await expect(app.noteTreeActiveNote).toContainText("Text notes");
// Refresh the page and check the order.
await app.goto( { preserveTabs: true });
- await expect(app.getTab(0)).toContainText("Code notes");
- await expect(app.getTab(1)).toContainText("Text notes");
- await expect(app.getTab(2)).toContainText("Mermaid");
+ await expect(await app.getTab(0)).toContainText("Code notes");
+ await expect(await app.getTab(1)).toContainText("Text notes");
+ await expect(await app.getTab(2)).toContainText("Mermaid");
// Check the note tree has the right active node.
await expect(app.noteTreeActiveNote).toContainText("Text notes");
@@ -118,7 +118,7 @@ test("Search works when dismissing a tab", async ({ page, context }) => {
await app.addNewTab();
await app.goToNoteInNewTab("Sample mindmap");
- await app.getTab(0).click();
+ await (await app.getTab(0)).click();
await app.openAndClickNoteActionMenu("Search in note");
await expect(app.findAndReplaceWidget.first()).toBeVisible();
});
diff --git a/apps/server-e2e/src/support/app.ts b/apps/server-e2e/src/support/app.ts
index da229607b..392201919 100644
--- a/apps/server-e2e/src/support/app.ts
+++ b/apps/server-e2e/src/support/app.ts
@@ -26,6 +26,7 @@ export default class App {
readonly currentNoteSplitTitle: Locator;
readonly currentNoteSplitContent: Locator;
readonly sidebar: Locator;
+ private isMobile: boolean = false;
constructor(page: Page, context: BrowserContext) {
this.page = page;
@@ -43,6 +44,8 @@ export default class App {
}
async goto({ url, isMobile, preserveTabs }: GotoOpts = {}) {
+ this.isMobile = !!isMobile;
+
await this.context.addCookies([
{
url: BASE_URL,
@@ -83,7 +86,12 @@ export default class App {
await this.page.locator(".launcher-button.bx-cog").click();
}
- getTab(tabIndex: number) {
+ async getTab(tabIndex: number) {
+ if (this.isMobile) {
+ await this.launcherBar.locator(".mobile-tab-switcher").click();
+ return this.page.locator(".modal.tab-bar-modal .tab-card").nth(tabIndex);
+ }
+
return this.tabBar.locator(".note-tab-wrapper").nth(tabIndex);
}
@@ -97,7 +105,8 @@ export default class App {
async closeAllTabs() {
await this.triggerCommand("closeAllTabs");
// Page in Playwright is not updated somehow, need to click on the tab to make sure it's rendered
- await this.getTab(0).click();
+ const tab = await this.getTab(0);
+ await tab.click();
}
/**
diff --git a/apps/server/src/assets/translations/en/server.json b/apps/server/src/assets/translations/en/server.json
index e6fa04b11..f6b23d489 100644
--- a/apps/server/src/assets/translations/en/server.json
+++ b/apps/server/src/assets/translations/en/server.json
@@ -356,7 +356,8 @@
"visible-launchers-title": "Visible Launchers",
"user-guide": "User Guide",
"localization": "Language & Region",
- "inbox-title": "Inbox"
+ "inbox-title": "Inbox",
+ "tab-switcher-title": "Tab Switcher"
},
"notes": {
"new-note": "New note",
diff --git a/apps/server/src/services/hidden_subtree_launcherbar.ts b/apps/server/src/services/hidden_subtree_launcherbar.ts
index d68c10c1c..55a3b6f70 100644
--- a/apps/server/src/services/hidden_subtree_launcherbar.ts
+++ b/apps/server/src/services/hidden_subtree_launcherbar.ts
@@ -48,7 +48,7 @@ export default function buildLaunchBarConfig() {
id: "_lbBackInHistory",
...sharedLaunchers.backInHistory
},
- {
+ {
id: "_lbForwardInHistory",
...sharedLaunchers.forwardInHistory
},
@@ -59,12 +59,12 @@ export default function buildLaunchBarConfig() {
command: "commandPalette",
icon: "bx bx-chevron-right-square"
},
- {
+ {
id: "_lbBackendLog",
title: t("hidden-subtree.backend-log-title"),
type: "launcher",
targetNoteId: "_backendLog",
- icon: "bx bx-detail"
+ icon: "bx bx-detail"
},
{
id: "_zenMode",
@@ -128,7 +128,7 @@ export default function buildLaunchBarConfig() {
baseSize: "50",
growthFactor: "0"
},
- {
+ {
id: "_lbBookmarks",
title: t("hidden-subtree.bookmarks-title"),
type: "launcher",
@@ -139,7 +139,7 @@ export default function buildLaunchBarConfig() {
id: "_lbToday",
...sharedLaunchers.openToday
},
- {
+ {
id: "_lbSpacer2",
title: t("hidden-subtree.spacer-title"),
type: "launcher",
@@ -179,7 +179,11 @@ export default function buildLaunchBarConfig() {
const mobileAvailableLaunchers: HiddenSubtreeItem[] = [
{ id: "_lbMobileNewNote", ...sharedLaunchers.newNote },
- { id: "_lbMobileToday", ...sharedLaunchers.openToday }
+ { id: "_lbMobileToday", ...sharedLaunchers.openToday },
+ {
+ id: "_lbMobileRecentChanges",
+ ...sharedLaunchers.recentChanges
+ }
];
const mobileVisibleLaunchers: HiddenSubtreeItem[] = [
@@ -203,8 +207,10 @@ export default function buildLaunchBarConfig() {
...sharedLaunchers.calendar
},
{
- id: "_lbMobileRecentChanges",
- ...sharedLaunchers.recentChanges
+ id: "_lbMobileTabSwitcher",
+ title: t("hidden-subtree.tab-switcher-title"),
+ type: "launcher",
+ builtinWidget: "mobileTabSwitcher"
}
];
@@ -214,4 +220,4 @@ export default function buildLaunchBarConfig() {
mobileAvailableLaunchers,
mobileVisibleLaunchers
};
-}
\ No newline at end of file
+}
diff --git a/packages/commons/src/lib/hidden_subtree.ts b/packages/commons/src/lib/hidden_subtree.ts
index 2fcd68f11..91e46b708 100644
--- a/packages/commons/src/lib/hidden_subtree.ts
+++ b/packages/commons/src/lib/hidden_subtree.ts
@@ -45,7 +45,8 @@ export interface HiddenSubtreeItem {
| "quickSearch"
| "aiChatLauncher"
| "commandPalette"
- | "toggleZenMode";
+ | "toggleZenMode"
+ | "mobileTabSwitcher";
command?: keyof typeof Command;
/**
* If set to true, then branches will be enforced to be in the correct place.