diff --git a/apps/client/src/desktop.ts b/apps/client/src/desktop.ts index e77ba845b..b88eb8d4a 100644 --- a/apps/client/src/desktop.ts +++ b/apps/client/src/desktop.ts @@ -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) { diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 41a6bb68f..e03e73d09 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -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, ) + .child() .child(new TabRowWidget().class("full-width")) .optChild(customTitleBarButtons, ) .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, ).css("height", "40px")) + .optChild(!fullWidthTabBar, + new FlexContainer("row") + .child() + .child(new TabRowWidget()) + .optChild(customTitleBarButtons, ) + .css("height", "40px")) .child( new FlexContainer("row") .filling() diff --git a/apps/client/src/services/tree.ts b/apps/client/src/services/tree.ts index cfa210600..00e88cf6d 100644 --- a/apps/client/src/services/tree.ts +++ b/apps/client/src/services/tree.ts @@ -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) { diff --git a/apps/client/src/stylesheets/theme-next/shell.css b/apps/client/src/stylesheets/theme-next/shell.css index 7643a02b2..e3e92cd7d 100644 --- a/apps/client/src/stylesheets/theme-next/shell.css +++ b/apps/client/src/stylesheets/theme-next/shell.css @@ -502,7 +502,7 @@ div.bookmark-folder-widget .note-link .bx { font-size: 1.2em; } -/* +/* * QUICK SEARCH BOX */ @@ -613,7 +613,7 @@ div.quick-search .dropdown-menu { * As a temporary workaround, the backdrop and transparency are disabled for the * vertical layout. */ -body.layout-vertical.background-effects div.quick-search .dropdown-menu { +body.layout-vertical.background-effects div.quick-search .dropdown-menu { --menu-background-color: var(--menu-background-color-no-backdrop) !important; } @@ -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; @@ -1569,7 +1583,7 @@ div.floating-buttons .show-floating-buttons-button { div.floating-buttons .show-floating-buttons-button::before { animation: floating-buttons-show-hide-button-animation 400ms ease-out; } - + div.floating-buttons .show-floating-buttons-button:hover, div.floating-buttons .show-floating-buttons-button:active { box-shadow: var(--floating-button-show-button-hover-shadow); @@ -1831,7 +1845,7 @@ div.find-replace-widget div.find-widget-found-wrapper > span { .excalidraw .dropdown-menu { border: unset !important; - box-shadow: unset !important; + box-shadow: unset !important; background-color: transparent !important; --island-bg-color: var(--menu-background-color); --shadow-island: 0px 10px 20px rgba(0, 0, 0, var(--dropdown-shadow-opacity)); @@ -1850,4 +1864,4 @@ div.find-replace-widget div.find-widget-found-wrapper > span { .excalidraw .dropdown-menu:before { content: unset !important; -} \ No newline at end of file +} diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index fea8ca13f..24e30c6a9 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -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" } } diff --git a/apps/client/src/widgets/TabHistoryNavigationButtons.css b/apps/client/src/widgets/TabHistoryNavigationButtons.css new file mode 100644 index 000000000..4d040b9a5 --- /dev/null +++ b/apps/client/src/widgets/TabHistoryNavigationButtons.css @@ -0,0 +1,7 @@ +.component.tab-history-navigation-buttons { + contain: none; + flex-shrink: 0; + display: flex; + align-items: center; + margin-inline-end: 0.5em; +} diff --git a/apps/client/src/widgets/TabHistoryNavigationButtons.tsx b/apps/client/src/widgets/TabHistoryNavigationButtons.tsx new file mode 100644 index 000000000..07ecf6b66 --- /dev/null +++ b/apps/client/src/widgets/TabHistoryNavigationButtons.tsx @@ -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() && +
+ {!legacyBackVisible && } + {!legacyForwardVisible && } +
+ ); +} + +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 }; +} diff --git a/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx b/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx index f9ea51c57..3e0ce6f96 100644 --- a/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx +++ b/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx @@ -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,71 +18,64 @@ const HISTORY_LIMIT = 20; export default function HistoryNavigationButton({ launcherNote, command }: HistoryNavigationProps) { const { icon, title } = useLauncherIconAndTitle(launcherNote); - const webContentsRef = useRef(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 ( { - e.preventDefault(); - - const webContents = webContentsRef.current; - if (!webContents || webContents.navigationHistory.length() < 2) { - return; - } - - let items: MenuCommandItem[] = []; - - const history = webContents.navigationHistory.getAllEntries(); - const activeIndex = webContents.navigationHistory.getActiveIndex(); - - for (const idx in history) { - const { notePath } = link.parseNavigationStateFromUrl(history[idx].url); - if (!notePath) continue; - - const title = await tree.getNotePathTitle(notePath); - - 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" - }); - } - - items.reverse(); - - if (items.length > HISTORY_LIMIT) { - items = items.slice(0, HISTORY_LIMIT); - } - - contextMenu.show({ - x: e.pageX, - y: e.pageY, - items, - selectMenuItemHandler: (item: MenuCommandItem) => { - if (item && item.command && webContents) { - const idx = parseInt(item.command, 10); - webContents.navigationHistory.goToIndex(idx); - } - } - }); - }} + onContextMenu={webContents ? handleHistoryContextMenu(webContents) : undefined} /> - ) + ); +} + +export function handleHistoryContextMenu(webContents: WebContents) { + return async (e: MouseEvent) => { + e.preventDefault(); + + if (!webContents || webContents.navigationHistory.length() < 2) { + return; + } + + let items: MenuCommandItem[] = []; + + const history = webContents.navigationHistory.getAllEntries(); + const activeIndex = webContents.navigationHistory.getActiveIndex(); + + for (const idx in history) { + 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, + checked: index === activeIndex, + enabled: index !== activeIndex, + uiIcon: note?.getIcon() + }); + } + + items.reverse(); + + if (items.length > HISTORY_LIMIT) { + items = items.slice(0, HISTORY_LIMIT); + } + + contextMenu.show({ + x: e.pageX, + y: e.pageY, + items, + selectMenuItemHandler: (item: MenuCommandItem) => { + if (item && item.command && webContents) { + const idx = parseInt(item.command, 10); + webContents.navigationHistory.goToIndex(idx); + } + } + }); + }; } diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 0c9883df2..fbcd7095e 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -897,7 +897,31 @@ export function useChildNotes(parentNoteId: string | undefined) { } setChildNotes(childNotes ?? []); })(); - }, [ parentNoteId ]); + }, [ parentNoteId ]); 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(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; +} diff --git a/apps/client/src/widgets/ribbon/NotePathsTab.tsx b/apps/client/src/widgets/ribbon/NotePathsTab.tsx index 00249b0b4..0b997cfb2 100644 --- a/apps/client/src/widgets/ribbon/NotePathsTab.tsx +++ b/apps/client/src/widgets/ribbon/NotePathsTab.tsx @@ -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(); @@ -33,7 +34,7 @@ export default function NotePathsTab({ note, hoistedNoteId, notePath }: TabConte
{sortedNotePaths?.length ? t("note_paths.intro_placed") : t("note_paths.intro_not_placed")}
- +
    {sortedNotePaths?.length ? sortedNotePaths.map(sortedNotePath => ( )) : undefined}
- +