fix(settings): simply setting modal scroll to section logic and highlight

- Renamed SettingSectionHighlightContext to SettingSectionHighlightIdContext for clarity.
- Simplified highlight registration logic and improved scrolling behavior in SettingModalContent.
- Enhanced visual feedback for highlighted sections with a new animation effect.
- Cleaned up unused code and optimized imports for better maintainability.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-10-30 18:11:02 +08:00
parent 0661c5fc1e
commit 9f192f3cbf
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
3 changed files with 63 additions and 71 deletions

View File

@ -8,7 +8,6 @@ import {
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
@ -19,7 +18,7 @@ import { ModalClose } from "~/components/ui/modal/stacked/components"
import { SettingsTitle } from "~/modules/settings/title"
import { useAvailableSettings } from "../hooks/use-setting-ctx"
import { SettingSectionHighlightContext } from "../section"
import { SettingSectionHighlightIdContext } from "../section"
import { getSettingPages } from "../settings-glob"
import type { SettingPageConfig } from "../utils"
import { SettingTabProvider, useSettingTab } from "./context"
@ -46,83 +45,54 @@ export const SettingModalContent: FC<{
}
const Content: FC<{
initialSection?: string
initialSection?: string | null
}> = ({ initialSection }) => {
const key = useDeferredValue(useSettingTab() || "general")
const pages = getSettingPages()
const { Component, loader } = pages[key]
const [scroller, setScroller] = useState<HTMLDivElement | null>(null)
const sectionRefs = useRef(new Map<string, HTMLElement>())
const pendingSectionRef = useRef<string | null>(initialSection ?? null)
const hasAppliedInitialSectionRef = useRef(false)
const [highlightedSectionId, setHighlightedSectionId] = useState<string | undefined>()
useEffect(() => {
pendingSectionRef.current = initialSection ?? null
hasAppliedInitialSectionRef.current = false
setHighlightedSectionId(undefined)
}, [initialSection])
useLayoutEffect(() => {
if (scroller) {
scroller.scrollTop = 0
}
}, [key])
}, [key, scroller])
const scrollToSection = useCallback((sectionId: string) => {
if (!sectionId) return false
const element = sectionRefs.current.get(sectionId)
if (!element) return false
const scrollToSection = useCallback(
(sectionId: string) => {
if (!sectionId) return false
if (!scroller) return false
const element = scroller.querySelector(`[data-setting-section="${sectionId}"]`) as HTMLElement
if (!element) return false
element.scrollIntoView({
behavior: "smooth",
block: "start",
})
const elementTop = element.offsetTop
const scrollerTop = scroller.scrollTop
const delta = elementTop - scrollerTop
setHighlightedSectionId(sectionId)
scroller.scrollTo({
top: delta,
behavior: "smooth",
})
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)
}
return true
},
[scrollToSection],
[scroller],
)
useEffect(() => {
if (!initialSection || hasAppliedInitialSectionRef.current) return
const handled = scrollToSection(initialSection)
if (handled) {
hasAppliedInitialSectionRef.current = true
pendingSectionRef.current = null
} else {
pendingSectionRef.current = initialSection
if (initialSection) {
scrollToSection(initialSection)
}
}, [initialSection, key, scrollToSection])
const highlightContextValue = useMemo(
() => ({
highlightedSectionId: highlightedSectionId ?? undefined,
registerSection,
}),
[highlightedSectionId, registerSection],
)
}, [initialSection, scrollToSection])
const config = (useLoaderData() || loader || {}) as SettingPageConfig
if (!Component) return null
@ -140,9 +110,9 @@ const Content: FC<{
config.viewportClassName,
)}
>
<SettingSectionHighlightContext value={highlightContextValue}>
<SettingSectionHighlightIdContext value={initialSection!}>
<Component />
</SettingSectionHighlightContext>
</SettingSectionHighlightIdContext>
<div className="h-16" />
<p className="absolute inset-x-0 bottom-4 flex items-center justify-center gap-1 text-xs opacity-80">

View File

@ -9,13 +9,7 @@ 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<SettingSectionHighlightContextValue | null>(null)
export const SettingSectionHighlightIdContext = createContext<string | null>(null)
export const SettingSectionTitle: FC<{
title: string | ReactNode
@ -23,32 +17,59 @@ export const SettingSectionTitle: FC<{
margin?: "compact" | "normal"
sectionId?: string
}> = ({ title, margin, className, sectionId }) => {
const highlightCtx = use(SettingSectionHighlightContext)
const highlightedSectionId = use(SettingSectionHighlightIdContext)
const elementRef = useRef<HTMLDivElement | null>(null)
const isHighlighted = !!sectionId && highlightedSectionId === sectionId && !!elementRef.current
useEffect(() => {
if (!sectionId || !highlightCtx) return
highlightCtx.registerSection(sectionId, elementRef.current)
return () => {
highlightCtx.registerSection(sectionId, null)
if (!isHighlighted) {
return
}
}, [highlightCtx, sectionId])
const isHighlighted =
!!sectionId && highlightCtx?.highlightedSectionId === sectionId && !!elementRef.current
let rollingAnimation: Animation | null = null
const timer = setTimeout(() => {
const highlightedElement = elementRef.current?.querySelector(
"[data-highlighted-element]",
) as HTMLElement
if (!highlightedElement) {
clearTimeout(timer)
return
}
const keyframeEffect = new KeyframeEffect(
highlightedElement,
[
{
backgroundColor: "color-mix(in srgb, hsl(var(--fo-a)) 33%, hsl(var(--background)) 67%)",
},
{ backgroundColor: "transparent" },
],
{
duration: 1000,
easing: "ease-in-out",
},
)
rollingAnimation = new Animation(keyframeEffect, document.timeline)
rollingAnimation.play()
}, 500)
return () => {
rollingAnimation?.cancel()
clearTimeout(timer)
}
}, [isHighlighted])
return (
<div
ref={elementRef}
data-setting-section={sectionId}
data-highlighted={isHighlighted ? "true" : undefined}
className={cn(
"shrink-0 text-headline font-bold text-text opacity-50 transition-colors duration-300 first:mt-0",
"relative shrink-0 text-headline font-bold text-text/80 first:mt-0",
margin === "compact" ? "mb-2 mt-8" : "mb-4 mt-10",
isHighlighted && "-ml-3 rounded-lg border border-folo px-3 py-1.5 opacity-100",
className,
)}
>
{isHighlighted && <div className="absolute -inset-4 rounded-lg" data-highlighted-element />}
{typeof title === "string" ? titleCase(title) : title}
</div>
)

View File

@ -34,6 +34,7 @@ export const TaskSchedulingSection = () => {
size={"sm"}
variant={"outline"}
onClick={handleCreateTask}
buttonClassName="!-translate-y-7"
>
<i className="i-mgc-add-cute-re mr-2 size-4" />
{t("tasks.actions.new_task")}