Back/forward navigation in tab bar (#8003)

This commit is contained in:
Elian Doran 2025-12-09 16:11:17 +02:00 committed by GitHub
commit 3b8dabc9d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 202 additions and 80 deletions

View File

@ -64,6 +64,9 @@ function initOnElectron() {
if (options.get("nativeTitleBarVisible") !== "true") {
initTitleBarButtons(style, currentWindow);
}
// Clear navigation history on frontend refresh.
currentWindow.webContents.navigationHistory.clear();
}
function initTitleBarButtons(style: CSSStyleDeclaration, currentWindow: Electron.BrowserWindow) {

View File

@ -45,6 +45,7 @@ import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx";
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
import Breadcrumb from "../widgets/Breadcrumb.jsx";
import TabHistoryNavigationButtons from "../widgets/TabHistoryNavigationButtons.jsx";
export default class DesktopLayout {
@ -80,6 +81,7 @@ export default class DesktopLayout {
.class("tab-row-container")
.child(new FlexContainer("row").id("tab-row-left-spacer"))
.optChild(launcherPaneIsHorizontal, <LeftPaneToggle isHorizontalLayout={true} />)
.child(<TabHistoryNavigationButtons />)
.child(new TabRowWidget().class("full-width"))
.optChild(customTitleBarButtons, <TitleBarButtons />)
.css("height", "40px")
@ -102,7 +104,12 @@ export default class DesktopLayout {
new FlexContainer("column")
.id("rest-pane")
.css("flex-grow", "1")
.optChild(!fullWidthTabBar, new FlexContainer("row").child(new TabRowWidget()).optChild(customTitleBarButtons, <TitleBarButtons />).css("height", "40px"))
.optChild(!fullWidthTabBar,
new FlexContainer("row")
.child(<TabHistoryNavigationButtons />)
.child(new TabRowWidget())
.optChild(customTitleBarButtons, <TitleBarButtons />)
.css("height", "40px"))
.child(
new FlexContainer("row")
.filling()

View File

@ -4,6 +4,8 @@ import froca from "./froca.js";
import hoistedNoteService from "./hoisted_note.js";
import appContext from "../components/app_context.js";
export const NOTE_PATH_TITLE_SEPARATOR = " ";
async function resolveNotePath(notePath: string, hoistedNoteId = "root") {
const runPath = await resolveNotePathToSegments(notePath, hoistedNoteId);
@ -254,7 +256,7 @@ async function getNotePathTitle(notePath: string) {
const titlePath = await getNotePathTitleComponents(notePath);
return titlePath.join(" / ");
return titlePath.join(NOTE_PATH_TITLE_SEPARATOR);
}
async function getNoteTitleWithPathAsSuffix(notePath: string) {

View File

@ -945,12 +945,26 @@ body.electron.background-effects.layout-horizontal .tab-row-container .toggle-bu
position: absolute;
bottom: 0;
inset-inline-start: -10px;
inset-inline-end: -10px;
inset-inline-end: -6px;
top: 32px;
height: 1px;
border-bottom: 1px solid var(--launcher-pane-horiz-border-color);
}
body.electron.background-effects.layout-horizontal .tab-row-container .tab-history-navigation-buttons {
position: relative;
&:after {
content: "";
position: absolute;
bottom: 0;
inset-inline-start: 0;
inset-inline-end: -7px;
height: 1px;
border-bottom: 1px solid var(--launcher-pane-horiz-border-color);
}
}
body.electron.background-effects.layout-horizontal .tab-row-container .tab-scroll-button-left,
body.electron.background-effects.layout-horizontal .tab-row-container .tab-scroll-button-right {
position: relative;

View File

@ -2117,5 +2117,9 @@
"unknown_http_error_title": "Communication error with the server",
"unknown_http_error_content": "Status code: {{statusCode}}\nURL: {{method}} {{url}}\nMessage: {{message}}",
"traefik_blocks_requests": "If you are using the Traefik reverse proxy, it introduced a breaking change which affects the communication with the server."
},
"tab_history_navigation_buttons": {
"go-back": "Go back to previous note",
"go-forward": "Go forward to next note"
}
}

View File

@ -0,0 +1,7 @@
.component.tab-history-navigation-buttons {
contain: none;
flex-shrink: 0;
display: flex;
align-items: center;
margin-inline-end: 0.5em;
}

View File

@ -0,0 +1,64 @@
import "./TabHistoryNavigationButtons.css";
import { useEffect, useMemo, useState } from "preact/hooks";
import { t } from "../services/i18n";
import { dynamicRequire, isElectron } from "../services/utils";
import { handleHistoryContextMenu } from "./launch_bar/HistoryNavigation";
import ActionButton from "./react/ActionButton";
import { useLauncherVisibility } from "./react/hooks";
export default function TabHistoryNavigationButtons() {
const webContents = useMemo(() => isElectron() ? dynamicRequire("@electron/remote").getCurrentWebContents() : undefined, []);
const onContextMenu = webContents ? handleHistoryContextMenu(webContents) : undefined;
const { canGoBack, canGoForward } = useBackForwardState(webContents);
const legacyBackVisible = useLauncherVisibility("_lbBackInHistory");
const legacyForwardVisible = useLauncherVisibility("_lbForwardInHistory");
return (isElectron() &&
<div className="tab-history-navigation-buttons">
{!legacyBackVisible && <ActionButton
icon="bx bx-left-arrow-alt"
text={t("tab_history_navigation_buttons.go-back")}
triggerCommand="backInNoteHistory"
onContextMenu={onContextMenu}
disabled={!canGoBack}
/>}
{!legacyForwardVisible && <ActionButton
icon="bx bx-right-arrow-alt"
text={t("tab_history_navigation_buttons.go-forward")}
triggerCommand="forwardInNoteHistory"
onContextMenu={onContextMenu}
disabled={!canGoForward}
/>}
</div>
);
}
function useBackForwardState(webContents: Electron.WebContents | undefined) {
const [ canGoBack, setCanGoBack ] = useState(webContents?.navigationHistory.canGoBack());
const [ canGoForward, setCanGoForward ] = useState(webContents?.navigationHistory.canGoForward());
useEffect(() => {
if (!webContents) return;
const updateNavigationState = () => {
setCanGoBack(webContents.navigationHistory.canGoBack());
setCanGoForward(webContents.navigationHistory.canGoForward());
};
webContents.on("did-navigate", updateNavigationState);
webContents.on("did-navigate-in-page", updateNavigationState);
return () => {
webContents.removeListener("did-navigate", updateNavigationState);
webContents.removeListener("did-navigate-in-page", updateNavigationState);
};
}, [ webContents ]);
if (!webContents) {
return { canGoBack: true, canGoForward: true };
}
return { canGoBack, canGoForward };
}

View File

@ -1,11 +1,13 @@
import { useEffect, useRef } from "preact/hooks";
import type { WebContents } from "electron";
import { useMemo } from "preact/hooks";
import FNote from "../../entities/fnote";
import contextMenu, { MenuCommandItem } from "../../menus/context_menu";
import froca from "../../services/froca";
import link from "../../services/link";
import tree from "../../services/tree";
import { dynamicRequire, isElectron } from "../../services/utils";
import { LaunchBarActionButton, useLauncherIconAndTitle } from "./launch_bar_widgets";
import type { WebContents } from "electron";
import contextMenu, { MenuCommandItem } from "../../menus/context_menu";
import tree from "../../services/tree";
import link from "../../services/link";
interface HistoryNavigationProps {
launcherNote: FNote;
@ -16,26 +18,22 @@ const HISTORY_LIMIT = 20;
export default function HistoryNavigationButton({ launcherNote, command }: HistoryNavigationProps) {
const { icon, title } = useLauncherIconAndTitle(launcherNote);
const webContentsRef = useRef<WebContents>(null);
useEffect(() => {
if (isElectron()) {
const webContents = dynamicRequire("@electron/remote").getCurrentWebContents();
// without this, the history is preserved across frontend reloads
webContents?.clearHistory();
webContentsRef.current = webContents;
}
}, []);
const webContents = useMemo(() => isElectron() ? dynamicRequire("@electron/remote").getCurrentWebContents() : undefined, []);
return (
<LaunchBarActionButton
icon={icon}
text={title}
triggerCommand={command}
onContextMenu={async (e) => {
onContextMenu={webContents ? handleHistoryContextMenu(webContents) : undefined}
/>
);
}
export function handleHistoryContextMenu(webContents: WebContents) {
return async (e: MouseEvent) => {
e.preventDefault();
const webContents = webContentsRef.current;
if (!webContents || webContents.navigationHistory.length() < 2) {
return;
}
@ -46,20 +44,19 @@ export default function HistoryNavigationButton({ launcherNote, command }: Histo
const activeIndex = webContents.navigationHistory.getActiveIndex();
for (const idx in history) {
const { notePath } = link.parseNavigationStateFromUrl(history[idx].url);
if (!notePath) continue;
const { noteId, notePath } = link.parseNavigationStateFromUrl(history[idx].url);
if (!noteId || !notePath) continue;
const title = await tree.getNotePathTitle(notePath);
const index = parseInt(idx, 10);
const note = froca.getNoteFromCache(noteId);
items.push({
title,
command: idx,
uiIcon:
parseInt(idx) === activeIndex
? "bx bx-radio-circle-marked" // compare with type coercion!
: parseInt(idx) < activeIndex
? "bx bx-left-arrow-alt"
: "bx bx-right-arrow-alt"
checked: index === activeIndex,
enabled: index !== activeIndex,
uiIcon: note?.getIcon()
});
}
@ -80,7 +77,5 @@ export default function HistoryNavigationButton({ launcherNote, command }: Histo
}
}
});
}}
/>
)
};
}

View File

@ -901,3 +901,27 @@ export function useChildNotes(parentNoteId: string | undefined) {
return childNotes;
}
export function useLauncherVisibility(launchNoteId: string) {
const checkIfVisible = useCallback(() => {
const note = froca.getNoteFromCache(launchNoteId);
return note?.getParentBranches().some(branch =>
[ "_lbVisibleLaunchers", "_lbMobileVisibleLaunchers" ].includes(branch.parentNoteId)) ?? false;
}, [ launchNoteId ]);
const [ isVisible, setIsVisible ] = useState<boolean>(checkIfVisible());
// React to note not being available in the cache.
useEffect(() => {
froca.getNote(launchNoteId).then(() => setIsVisible(checkIfVisible()));
}, [ launchNoteId, checkIfVisible ]);
// React to changes.
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getBranchRows().some(branch => branch.noteId === launchNoteId)) {
setIsVisible(checkIfVisible());
}
});
return isVisible;
}

View File

@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from "preact/hooks";
import { NotePathRecord } from "../../entities/fnote";
import NoteLink from "../react/NoteLink";
import { joinElements } from "../react/react_utils";
import { NOTE_PATH_TITLE_SEPARATOR } from "../../services/tree";
export default function NotePathsTab({ note, hoistedNoteId, notePath }: TabContext) {
const [ sortedNotePaths, setSortedNotePaths ] = useState<NotePathRecord[]>();
@ -95,7 +96,7 @@ function NotePath({ currentNotePath, notePathRecord }: { currentNotePath?: strin
<li class={classes}>
{joinElements(fullNotePaths.map(notePath => (
<NoteLink notePath={notePath} noPreview />
)), " / ")}
)), NOTE_PATH_TITLE_SEPARATOR)}
{icons.map(({ icon, title }) => (
<span class={icon} title={title} />

View File

@ -36,10 +36,11 @@
display: flex;
align-items: center;
font-size: 0.9em;
padding-top: 2px;
}
.ribbon-tab-title .bx {
font-size: 125%;
font-size: 150%;
position: relative;
}