diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatShortcutsRow.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatShortcutsRow.tsx index 77c9da700..5b57de933 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatShortcutsRow.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatShortcutsRow.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next" import { useAISettingValue } from "~/atoms/settings/ai" import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack" +import { AI_SETTING_SECTION_IDS } from "~/modules/settings/tabs/ai" import { useCreateAIShortcutModal } from "~/modules/settings/tabs/ai/shortcuts/hooks" import { useMainEntryId } from "../../hooks/useMainEntryId" @@ -44,7 +45,7 @@ export const ChatShortcutsRow: React.FC = ({ onSelect }) const handleAddShortcut = useCreateAIShortcutModal() const handleCustomize = useCallback(() => { - showSettings("ai") + showSettings({ tab: "ai", section: AI_SETTING_SECTION_IDS.shortcuts }) nextFrame(() => { handleAddShortcut() }) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/TaskReportDropdown.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/TaskReportDropdown.tsx index 414c42e53..e1acec8ec 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/TaskReportDropdown.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/TaskReportDropdown.tsx @@ -24,6 +24,7 @@ import { } from "~/modules/ai-chat-session/query" import { AITaskModal, useCanCreateNewAITask } from "~/modules/ai-task" import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack" +import { AI_SETTING_SECTION_IDS } from "~/modules/settings/tabs/ai" import { AIPersistService } from "../../services" @@ -114,7 +115,7 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor toast.error("Please remove an existing task before creating a new one.") return } - showSettings("ai") + showSettings({ tab: "ai", section: AI_SETTING_SECTION_IDS.tasks }) nextFrame(() => { present({ title: "New AI Task", diff --git a/apps/desktop/layer/renderer/src/modules/settings/helper/setting-builder.tsx b/apps/desktop/layer/renderer/src/modules/settings/helper/setting-builder.tsx index ea8d981bf..73fcf9214 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/helper/setting-builder.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/helper/setting-builder.tsx @@ -30,6 +30,7 @@ export type SettingItem = { type SectionSettingItem = { type: "title" value?: string + id?: string } & SharedSettingItem type ActionSettingItem = { @@ -78,7 +79,13 @@ export const createSettingBuilder = !isEmptySection if (isValidTitle) { - return + return ( + + ) } if ("type" in assertSetting && assertSetting.type === "title") { return null diff --git a/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx b/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx index 11a83d72b..f74d94487 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx @@ -2,13 +2,23 @@ import { ScrollArea } from "@follow/components/ui/scroll-area/index.js" import { cn } from "@follow/utils" import { repository } from "@pkg" import type { FC } from "react" -import { Suspense, useDeferredValue, useLayoutEffect, useState } from "react" +import { + Suspense, + useCallback, + useDeferredValue, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react" import { Trans } from "react-i18next" import { useLoaderData } from "react-router" import { ModalClose } from "~/components/ui/modal/stacked/components" import { SettingsTitle } from "~/modules/settings/title" +import { SettingSectionHighlightContext } from "../section" import { getSettingPages } from "../settings-glob" import type { SettingPageConfig } from "../utils" import { useSettingTab } from "./context" @@ -16,23 +26,37 @@ import { SettingModalLayout } from "./layout" export const SettingModalContent: FC<{ initialTab?: string -}> = ({ initialTab }) => { + initialSection?: string +}> = ({ initialTab, initialSection }) => { const pages = getSettingPages() + const resolvedInitialTab = initialTab && initialTab in pages ? initialTab : undefined + return ( - - + + ) } -const Content = () => { +const Content: FC<{ + initialTab?: string + initialSection?: string +}> = ({ initialTab, initialSection }) => { const key = useDeferredValue(useSettingTab() || "general") const pages = getSettingPages() const { Component, loader } = pages[key] const [scroller, setScroller] = useState(null) + const sectionRefs = useRef(new Map()) + const pendingSectionRef = useRef(initialSection ?? null) + const hasAppliedInitialSectionRef = useRef(false) + const [highlightedSectionId, setHighlightedSectionId] = useState() + + useEffect(() => { + pendingSectionRef.current = initialSection ?? null + hasAppliedInitialSectionRef.current = false + setHighlightedSectionId(undefined) + }, [initialSection]) useLayoutEffect(() => { if (scroller) { @@ -40,6 +64,61 @@ const Content = () => { } }, [key]) + const scrollToSection = useCallback((sectionId: string) => { + if (!sectionId) return false + const element = sectionRefs.current.get(sectionId) + if (!element) return false + + element.scrollIntoView({ + behavior: "smooth", + block: "start", + }) + + setHighlightedSectionId(sectionId) + + return true + }, []) + + const registerSection = useCallback( + (sectionId: string, element: HTMLElement | null) => { + if (!sectionId) return + if (element) { + sectionRefs.current.set(sectionId, element) + if (pendingSectionRef.current === sectionId) { + const handled = scrollToSection(sectionId) + if (handled) { + pendingSectionRef.current = null + hasAppliedInitialSectionRef.current = true + } + } + } else { + sectionRefs.current.delete(sectionId) + } + }, + [scrollToSection], + ) + + useEffect(() => { + if (!initialSection || hasAppliedInitialSectionRef.current) return + if (initialTab && key !== initialTab) return + + const handled = scrollToSection(initialSection) + if (handled) { + hasAppliedInitialSectionRef.current = true + pendingSectionRef.current = null + } else { + pendingSectionRef.current = initialSection + } + }, [initialSection, initialTab, key, scrollToSection]) + + const highlightContextValue = useMemo( + () => ({ + highlightedSectionId: highlightedSectionId ?? undefined, + registerSection, + }), + [highlightedSectionId, registerSection], + ) + const config = (useLoaderData() || loader || {}) as SettingPageConfig if (!Component) return null @@ -56,7 +135,9 @@ const Content = () => { config.viewportClassName, )} > - + + +

diff --git a/apps/desktop/layer/renderer/src/modules/settings/modal/use-setting-modal-hack.ts b/apps/desktop/layer/renderer/src/modules/settings/modal/use-setting-modal-hack.ts index a4985e4bc..2fea9b28c 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/modal/use-setting-modal-hack.ts +++ b/apps/desktop/layer/renderer/src/modules/settings/modal/use-setting-modal-hack.ts @@ -1,5 +1,6 @@ // HACK: Use expose the navigate function in the window object, avoid to import `router` circular issue. +import type { SettingModalOptions } from "./useSettingModal" -const showSettings = (args?: any) => window.router.showSettings.call(null, args) +const showSettings = (args?: SettingModalOptions) => window.router.showSettings.call(null, args) export const useSettingModal = () => showSettings diff --git a/apps/desktop/layer/renderer/src/modules/settings/modal/useSettingModal.ts b/apps/desktop/layer/renderer/src/modules/settings/modal/useSettingModal.ts index 3eaa35dd6..abe20bfc0 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/modal/useSettingModal.ts +++ b/apps/desktop/layer/renderer/src/modules/settings/modal/useSettingModal.ts @@ -5,17 +5,35 @@ import { useModalStack } from "~/components/ui/modal/stacked/hooks" import { SettingModalContent } from "./SettingModalContent" +export type SettingModalOptions = + | string + | { + tab?: string + section?: string + } + +const normalizeOptions = (options?: SettingModalOptions) => { + if (!options) return {} + if (typeof options === "string") { + return { tab: options } + } + return options +} + export const useSettingModal = () => { const { present } = useModalStack() return useCallback( - (initialTab?: string) => { + (options?: SettingModalOptions) => { + const { tab, section } = normalizeOptions(options) + return present({ title: "Setting", id: "setting", content: () => createElement(SettingModalContent, { - initialTab, + initialTab: tab, + initialSection: section, }), CustomModalComponent: PlainModal, modalContainerClassName: "overflow-hidden", diff --git a/apps/desktop/layer/renderer/src/modules/settings/section.tsx b/apps/desktop/layer/renderer/src/modules/settings/section.tsx index 7dc64a281..32ac4d2a2 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/section.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/section.tsx @@ -3,27 +3,56 @@ import { cn } from "@follow/utils/utils" import type { FC, PropsWithChildren, ReactNode } from "react" -import { cloneElement } from "react" +import { cloneElement, createContext, use, useEffect, useRef } from "react" import * as React from "react" import { titleCase } from "title-case" import { SettingActionItem, SettingDescription, SettingSwitch } from "./control" +type SettingSectionHighlightContextValue = { + highlightedSectionId?: string + registerSection: (sectionId: string, element: HTMLElement | null) => void +} + +export const SettingSectionHighlightContext = + createContext(null) + export const SettingSectionTitle: FC<{ title: string | ReactNode className?: string margin?: "compact" | "normal" -}> = ({ title, margin, className }) => ( -

- {typeof title === "string" ? titleCase(title) : title} -
-) + sectionId?: string +}> = ({ title, margin, className, sectionId }) => { + const highlightCtx = use(SettingSectionHighlightContext) + const elementRef = useRef(null) + + useEffect(() => { + if (!sectionId || !highlightCtx) return + highlightCtx.registerSection(sectionId, elementRef.current) + return () => { + highlightCtx.registerSection(sectionId, null) + } + }, [highlightCtx, sectionId]) + + const isHighlighted = + !!sectionId && highlightCtx?.highlightedSectionId === sectionId && !!elementRef.current + + return ( +
+ {typeof title === "string" ? titleCase(title) : title} +
+ ) +} export const SettingItemGroup: FC = ({ children }) => { const childrenArray = React.Children.toArray(children) diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/ai.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/ai.tsx index 727df3f27..f8550b8da 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/ai.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/ai.tsx @@ -15,6 +15,11 @@ import { UsageAnalysisSection } from "./ai/usage" const SettingBuilder = createSettingBuilder(useAISettingValue) const defineSettingItem = createDefineSettingItem(useAISettingValue, setAISetting) +export const AI_SETTING_SECTION_IDS = { + shortcuts: "settings-ai-shortcuts", + tasks: "settings-ai-tasks", +} as const + export const SettingAI = () => { const { t } = useTranslation("ai") @@ -47,6 +52,7 @@ export const SettingAI = () => { { type: "title", value: t("shortcuts.title"), + id: AI_SETTING_SECTION_IDS.shortcuts, }, AIShortcutsSection, @@ -59,6 +65,7 @@ export const SettingAI = () => { { type: "title", value: t("tasks.section.title"), + id: AI_SETTING_SECTION_IDS.tasks, }, TaskSchedulingSection, diff --git a/apps/desktop/layer/renderer/src/providers/extension-expose-provider.tsx b/apps/desktop/layer/renderer/src/providers/extension-expose-provider.tsx index 472b4f253..8b5e0f57a 100644 --- a/apps/desktop/layer/renderer/src/providers/extension-expose-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/extension-expose-provider.tsx @@ -18,13 +18,14 @@ import { navigateEntry } from "~/hooks/biz/useNavigateEntry" import { oneTimeToken } from "~/lib/auth" import { queryClient } from "~/lib/query-client" import { usePresentUserProfileModal } from "~/modules/profile/hooks" +import type { SettingModalOptions } from "~/modules/settings/modal/useSettingModal" import { useSettingModal } from "~/modules/settings/modal/useSettingModal" import { handleSessionChanges } from "~/queries/auth" import { clearDataIfLoginOtherAccount } from "~/store/utils/clear" declare module "@follow/components/providers/stable-router-provider.js" { interface CustomRoute { - showSettings: (path?: string) => void + showSettings: (options?: SettingModalOptions) => void } }