Mobile tabs v1 (#8568)

This commit is contained in:
Elian Doran
2026-01-31 22:13:58 +02:00
committed by GitHub
23 changed files with 510 additions and 104 deletions

View File

@@ -13,6 +13,7 @@
<body id="trilium-app">
<noscript>Trilium requires JavaScript to be enabled.</noscript>
<div id="context-menu-cover"></div>
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
<!-- Required to match the PWA's top bar color with the theme -->

View File

@@ -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")

View File

@@ -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<T> {
x: number;
@@ -62,17 +63,17 @@ export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEve
class ContextMenu {
private $widget: JQuery<HTMLElement>;
private $cover: JQuery<HTMLElement>;
private $cover?: JQuery<HTMLElement>;
private options?: ContextMenuOptions<any>;
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 = $(`<span class="badge">`).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();
}

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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"
}
}

View File

@@ -155,7 +155,7 @@ function NoteAttributes({ note }: { note: FNote }) {
return <span className="note-list-attributes" ref={ref} />;
}
function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes }: {
export function NoteContent({ note, trim, noChildrenList, highlightedTokens, includeArchivedNotes }: {
note: FNote;
trim?: boolean;
noChildrenList?: boolean;

View File

@@ -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 <QuickSearchLauncherWidget />;
case "aiChatLauncher":
return <AiChatButton launcherNote={note} />;
case "mobileTabSwitcher":
return <TabSwitcher />;
default:
throw new Error(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
}

View File

@@ -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<ActionButtonProps, "className" | "noIconActionClass" | "titlePosition">) {
export function LaunchBarActionButton({ className, ...props }: Omit<ActionButtonProps, "noIconActionClass" | "titlePosition">) {
const { isHorizontalLayout } = useContext(LaunchBarContext);
return (
<ActionButton
className="button-widget launcher-button"
className={clsx("button-widget launcher-button", className)}
noIconActionClass
titlePosition={isHorizontalLayout ? "bottom" : "right"}
{...props}

View File

@@ -5,6 +5,7 @@ import { Tooltip } from "bootstrap";
import clsx from "clsx";
import { ComponentChild } from "preact";
import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import type React from "react";
import { Trans } from "react-i18next";
import FNote from "../../entities/fnote";

View File

@@ -0,0 +1,133 @@
#launcher-container .mobile-tab-switcher {
position: relative;
&::after {
content: attr(data-tab-count);
font-family: var(--main-font-family);
font-size: 10px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
.modal.tab-bar-modal {
.modal-dialog {
min-height: 85vh;
}
.tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1em;
@media (min-width: 850px) {
grid-template-columns: 1fr 1fr 1fr;
}
.tab-card {
background: var(--card-background-color);
border-radius: 1em;
min-width: 0;
overflow: hidden;
height: 200px;
display: flex;
flex-direction: column;
&.with-hue {
background-color: hsl(var(--bg-hue), 8.8%, 11.2%);
border-color: hsl(var(--bg-hue), 9.4%, 25.1%);
}
&.active {
outline: 4px solid var(--more-accented-background-color);
background: var(--card-background-hover-color);
.title {
font-weight: bold;
}
}
header {
padding: 0.4em 0.5em;
border-bottom: 1px solid rgba(150, 150, 150, 0.1);
display: flex;
overflow: hidden;
align-items: center;
color: var(--custom-color, inherit);
flex-shrink: 0;
&:not(:first-of-type) {
border-top: 1px solid rgba(150, 150, 150, 0.1);
}
>.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;
}
}
}

View File

@@ -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 (
<>
<LaunchBarActionButton
className="mobile-tab-switcher"
icon="bx bx-rectangle"
text="Tabs"
onClick={() => setShown(true)}
data-tab-count={mainNoteContexts.length > 99 ? "∞" : mainNoteContexts.length}
/>
{createPortal(<TabBarModal mainNoteContexts={mainNoteContexts} shown={shown} setShown={setShown} />, 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 (
<Modal
className="tab-bar-modal"
size="xl"
title={t("mobile_tab_switcher.title", { count: mainNoteContexts.length})}
show={shown}
onShown={() => setFullyShown(true)}
customTitleBarButtons={[
{
iconClassName: "bx bx-dots-vertical-rounded",
title: t("mobile_tab_switcher.more_options"),
onClick(e) {
contextMenu.show<CommandNames>({
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={<>
<LinkButton
text={t("tab_row.new_tab")}
onClick={() => {
appContext.triggerCommand("openNewTab");
setShown(false);
}}
/>
</>}
scrollable
onHidden={() => {
setShown(false);
setFullyShown(false);
}}
>
<TabBarModelContent mainNoteContexts={mainNoteContexts} selectTab={selectTab} shown={fullyShown} />
</Modal>
);
}
function TabBarModelContent({ mainNoteContexts, selectTab, shown }: {
mainNoteContexts: NoteContext[];
shown: boolean;
selectTab: (noteContextToActivate: NoteContext) => void;
}) {
const activeNoteContext = useActiveNoteContext();
const tabRefs = useRef<Record<string, HTMLDivElement | null>>({});
// Scroll to active tab.
useEffect(() => {
if (!shown || !activeNoteContext?.ntxId) return;
const correspondingEl = tabRefs.current[activeNoteContext.ntxId];
requestAnimationFrame(() => {
correspondingEl?.scrollIntoView();
});
}, [ activeNoteContext, shown ]);
return (
<div className="tabs">
{mainNoteContexts.map((noteContext) => (
<Tab
key={noteContext.ntxId}
noteContext={noteContext}
activeNtxId={activeNoteContext.ntxId}
selectTab={selectTab}
containerRef={el => (tabRefs.current[noteContext.ntxId ?? ""] = el)}
/>
))}
</div>
);
}
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 (
<div
ref={containerRef}
class={clsx("tab-card", {
active: noteContext.ntxId === activeNtxId,
"with-hue": workspaceTabBackgroundColorHue !== undefined,
"with-split": subContexts.length > 1
})}
onClick={() => selectTab(noteContext)}
style={{
"--bg-hue": workspaceTabBackgroundColorHue
}}
>
{subContexts.map(subContext => (
<Fragment key={subContext.ntxId}>
<header className={colorClass}>
{subContext.note && <Icon icon={iconClass} />}
<span className="title">{subContext.note?.title ?? t("tab_row.new_tab")}</span>
{subContext.isMainContext() && <ActionButton
icon="bx bx-x"
text={t("tab_row.close_tab")}
onClick={(e) => {
// We are closing a tab, so we need to prevent propagation for click (activate tab).
e.stopPropagation();
appContext.tabManager.removeNoteContext(subContext.ntxId);
}}
/>}
</header>
<div className={clsx("tab-preview", `type-${subContext.note?.type ?? "empty"}`)}>
<TabPreviewContent note={subContext.note} />
</div>
</Fragment>
))}
</div>
);
}
function TabPreviewContent({ note }: {
note: FNote | null
}) {
if (!note) {
return <PreviewPlaceholder icon="bx bx-plus" />;
}
if (note.type === "book") {
return <PreviewPlaceholder icon={ICON_MAPPINGS[note.getLabelValue("viewType") ?? ""] ?? "bx bx-book"} />;
}
return (
<NoteContent
note={note}
highlightedTokens={undefined}
trim
includeArchivedNotes={false}
/>
);
}
function PreviewPlaceholder({ icon}: {
icon: string;
}) {
return (
<div className="preview-placeholder">
<Icon icon={icon} />
</div>
);
}
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;
}

View File

@@ -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<ViewTypeOptions, string> = {
export const ICON_MAPPINGS: Record<ViewTypeOptions, string> = {
grid: "bx bxs-grid",
list: "bx bx-list-ul",
calendar: "bx bx-calendar",

View File

@@ -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 }: {

View File

@@ -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<HTMLAttributes<HTMLButtonElement>, "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<HTMLAttributes<HTMLButtonElement>, "onClick" | "onAuxClick" | "onContextMenu" | "style"> {
text: string;
titlePosition?: "top" | "right" | "bottom" | "left";
icon: string;

View File

@@ -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<HTMLDivElement>(externalModalRef);
const modalInstanceRef = useRef<BootstrapModal>();
const elementToFocus = useRef<Element | null>();
@@ -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) => (
<button type="button"
className={clsx("custom-title-bar-button bx", titleBarButton.iconClassName)}
title={titleBarButton.title}
onClick={titleBarButton.onClick}>
</button>
className={clsx("custom-title-bar-button bx", titleBarButton.iconClassName)}
title={titleBarButton.title}
onClick={titleBarButton.onClick} />
))}
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label={t("modal.close")}></button>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label={t("modal.close")} />
</div>

View File

@@ -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() {
<ShareSettings />
<NetworkSettings />
</>
)
);
}
function SearchEngineSettings() {
@@ -82,7 +84,7 @@ function SearchEngineSettings() {
/>
</FormGroup>
</OptionsSection>
)
);
}
function TrayOptionsSettings() {
@@ -97,7 +99,7 @@ function TrayOptionsSettings() {
onChange={trayEnabled => setDisableTray(!trayEnabled)}
/>
</OptionsSection>
)
);
}
function NoteErasureTimeout() {
@@ -105,13 +107,13 @@ function NoteErasureTimeout() {
<OptionsSection title={t("note_erasure_timeout.note_erasure_timeout_title")}>
<FormText>{t("note_erasure_timeout.note_erasure_description")}</FormText>
<FormGroup name="erase-entities-after" label={t("note_erasure_timeout.erase_notes_after")}>
<TimeSelector
name="erase-entities-after"
<TimeSelector
name="erase-entities-after"
optionValueId="eraseEntitiesAfterTimeInSeconds" optionTimeScaleId="eraseEntitiesAfterTimeScale"
/>
</FormGroup>
<FormText>{t("note_erasure_timeout.manual_erasing_description")}</FormText>
<Button
text={t("note_erasure_timeout.erase_deleted_notes_now")}
onClick={() => {
@@ -121,7 +123,7 @@ function NoteErasureTimeout() {
}}
/>
</OptionsSection>
)
);
}
function AttachmentErasureTimeout() {
@@ -145,7 +147,7 @@ function AttachmentErasureTimeout() {
}}
/>
</OptionsSection>
)
);
}
function RevisionSnapshotInterval() {
@@ -165,7 +167,7 @@ function RevisionSnapshotInterval() {
/>
</FormGroup>
</OptionsSection>
)
);
}
function RevisionSnapshotLimit() {
@@ -176,7 +178,7 @@ function RevisionSnapshotLimit() {
<FormText>{t("revisions_snapshot_limit.note_revisions_snapshot_limit_description")}</FormText>
<FormGroup name="revision-snapshot-number-limit">
<FormTextBoxWithUnit
<FormTextBoxWithUnit
type="number" min={-1}
currentValue={revisionSnapshotNumberLimit}
unit={t("revisions_snapshot_limit.snapshot_number_limit_unit")}
@@ -197,7 +199,7 @@ function RevisionSnapshotLimit() {
}}
/>
</OptionsSection>
)
);
}
function HtmlImportTags() {
@@ -236,7 +238,7 @@ function HtmlImportTags() {
onClick={() => setAllowedHtmlTags(SANITIZER_DEFAULT_ALLOWED_TAGS)}
/>
</OptionsSection>
)
);
}
function ShareSettings() {
@@ -246,8 +248,8 @@ function ShareSettings() {
return (
<OptionsSection title={t("share.title")}>
<FormGroup name="redirectBareDomain" description={t("share.redirect_bare_domain_description")}>
<FormCheckbox
label={t(t("share.redirect_bare_domain"))}
<FormCheckbox
label={t(t("share.redirect_bare_domain"))}
currentValue={redirectBareDomain}
onChange={async value => {
if (value) {
@@ -264,17 +266,17 @@ function ShareSettings() {
}
setRedirectBareDomain(value);
}}
/>
/>
</FormGroup>
<FormGroup name="showLoginInShareTheme" description={t("share.show_login_link_description")}>
<FormCheckbox
<FormCheckbox
label={t("share.show_login_link")}
currentValue={showLogInShareTheme} onChange={setShowLogInShareTheme}
/>
</FormGroup>
</OptionsSection>
)
);
}
function NetworkSettings() {
@@ -288,5 +290,5 @@ function NetworkSettings() {
currentValue={checkForUpdates} onChange={setCheckForUpdates}
/>
</OptionsSection>
)
}
);
}

View File

@@ -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";

View File

@@ -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();
});

View File

@@ -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();
}
/**

View File

@@ -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",

View File

@@ -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
};
}
}

View File

@@ -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.