refactor(desktop): singleton modal stack present method

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-05-09 17:32:18 +08:00
parent 3e9fd61575
commit 91c8d312ea
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
7 changed files with 111 additions and 100 deletions

View File

@ -2,6 +2,8 @@ import type { FC, RefObject } from "react"
import { createContext as reactCreateContext } from "react"
import { createContext as createContextSelector } from "use-context-selector"
import type { ModalProps } from "./types"
export type CurrentModalContentProps = ModalActionsInternal & {
ref: RefObject<HTMLElement | null>
modalElementRef: RefObject<HTMLElement | null>
@ -37,3 +39,10 @@ export type ModalActionsInternal = {
setClickOutSideToDismiss: (value: boolean) => void
getIndex: () => number
}
type Disposer = () => void
type PresentModalContextInternalFn = (props: ModalProps & { id?: string }) => Disposer
export const PresentModalContextInternal = reactCreateContext<PresentModalContextInternalFn>(() => {
warnNoProvider()
return () => {}
})

View File

@ -1,75 +1,30 @@
import { Button } from "@follow/components/ui/button/index.js"
import { nextFrame } from "@follow/utils/dom"
import { atom, useAtomValue } from "jotai"
import type { DragControls } from "motion/react"
import type { ResizeCallback, ResizeStartCallback } from "re-resizable"
import { use, useId, useRef, useState } from "react"
import { use, useState } from "react"
import { flushSync } from "react-dom"
import { useTranslation } from "react-i18next"
import { useContextSelector } from "use-context-selector"
import { useEventCallback } from "usehooks-ts"
import { getUISettings } from "~/atoms/settings/ui"
import { jotaiStore } from "~/lib/jotai"
import { modalStackAtom } from "./atom"
import { ModalEventBus } from "./bus"
import { CurrentModalContext, CurrentModalStateContext } from "./context"
import type { DialogInstance, ModalProps, ModalStackOptions } from "./types"
import {
CurrentModalContext,
CurrentModalStateContext,
PresentModalContextInternal,
} from "./context"
import type { DialogInstance, ModalProps } from "./types"
export const modalIdToPropsMap = {} as Record<string, ModalProps>
export const useModalStack = (options?: ModalStackOptions) => {
const id = useId()
const currentCount = useRef(0)
const { wrapper } = options || {}
export const useModalStack = () => {
const present = use(PresentModalContextInternal)
return {
present: useEventCallback((props: ModalProps & { id?: string }) => {
const presentSync = (props: ModalProps & { id?: string }) => {
const fallbackModelId = `${id}-${++currentCount.current}`
const modalId = props.id ?? fallbackModelId
const currentStack = jotaiStore.get(modalStackAtom)
const existingModal = currentStack.find((item) => item.id === modalId)
if (existingModal) {
// Move to top
jotaiStore.set(modalStackAtom, (p) => {
const index = p.indexOf(existingModal)
return [...p.slice(0, index), ...p.slice(index + 1), existingModal]
})
} else {
// NOTE: The props of the Command Modal are immutable, so we'll just take the store value and inject it.
// There is no need to inject `overlay` props, this is rendered responsively based on ui changes.
const uiSettings = getUISettings()
const modalConfig: Partial<ModalProps> = {
draggable: uiSettings.modalDraggable,
modal: true,
}
jotaiStore.set(modalStackAtom, (p) => {
const modalProps: ModalProps = {
...modalConfig,
...props,
wrapper,
}
modalIdToPropsMap[modalId] = modalProps
return p.concat({
id: modalId,
...modalProps,
})
})
}
return () => {
jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== modalId))
}
}
return nextFrame(() => presentSync(props))
}),
present,
...actions,
}
}

View File

@ -1,17 +1,73 @@
import { nextFrame } from "@follow/utils/dom"
import type { FC, PropsWithChildren } from "react"
import { useId, useRef } from "react"
import { useEventCallback } from "usehooks-ts"
import { getUISettings } from "~/atoms/settings/ui"
import { jotaiStore } from "~/lib/jotai"
import { modalStackAtom } from "./atom"
import { PresentModalContextInternal } from "./context"
import { modalIdToPropsMap } from "./hooks"
import { ModalStack } from "./modal-stack"
import type { ModalProps } from "./types"
declare global {
interface Window {
presentModal: (modal: ModalProps) => void
presentModal: (modal: ModalProps & { id?: string }) => void
}
}
export const ModalStackProvider: FC<PropsWithChildren> = ({ children }) => (
<>
{children}
<ModalStack />
</>
)
export const ModalStackProvider: FC<PropsWithChildren> = ({ children }) => {
const id = useId()
const currentCount = useRef(0)
const presentModal = useEventCallback((props: ModalProps & { id?: string }) => {
const fallbackModelId = `${id}-${++currentCount.current}`
const modalId = props.id ?? fallbackModelId
const presentSync = (props: ModalProps & { id?: string }) => {
const currentStack = jotaiStore.get(modalStackAtom)
const existingModal = currentStack.find((item) => item.id === modalId)
if (existingModal) {
// Move to top
jotaiStore.set(modalStackAtom, (p) => {
const index = p.indexOf(existingModal)
return [...p.slice(0, index), ...p.slice(index + 1), existingModal]
})
} else {
// NOTE: The props of the Command Modal are immutable, so we'll just take the store value and inject it.
// There is no need to inject `overlay` props, this is rendered responsively based on ui changes.
const uiSettings = getUISettings()
const modalConfig: Partial<ModalProps> = {
draggable: uiSettings.modalDraggable,
modal: true,
}
jotaiStore.set(modalStackAtom, (p) => {
const modalProps: ModalProps = {
...modalConfig,
...props,
}
modalIdToPropsMap[modalId] = modalProps
return p.concat({
id: modalId,
...modalProps,
})
})
}
}
nextFrame(() => presentSync(props))
return () => {
jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== modalId))
}
})
return (
<PresentModalContextInternal value={presentModal}>
{children}
<ModalStack />
</PresentModalContextInternal>
)
}

View File

@ -32,9 +32,6 @@ export interface ModalProps {
autoFocus?: boolean
}
export interface ModalStackOptions {
wrapper?: FC
}
export interface DialogInstance {
ask: (options: {

View File

@ -2,6 +2,5 @@ export { ContextMenuProvider as LazyContextMenuProvider } from "../context-menu-
export { ExtensionExposeProvider as LazyExtensionExposeProvider } from "../extension-expose-provider"
export { ExternalJumpInProvider as LazyExternalJumpInProvider } from "../external-jump-in-provider"
export { LottieRenderContainer as LazyLottieRenderContainer } from "~/components/ui/lottie-container"
export { ModalStackProvider as LazyModalStackProvider } from "~/components/ui/modal"
export const LazyReloadPrompt = () => null
export const LazyPWAPrompt = () => null

View File

@ -10,11 +10,6 @@ const LazyContextMenuProvider = lazy(() =>
default: res.ContextMenuProvider,
})),
)
const LazyModalStackProvider = lazy(() =>
import("../../components/ui/modal/stacked/provider").then((res) => ({
default: res.ModalStackProvider,
})),
)
const LazyExtensionExposeProvider = lazy(() =>
import("./../extension-expose-provider").then((res) => ({
@ -52,7 +47,6 @@ export {
LazyContextMenuProvider,
LazyExtensionExposeProvider,
LazyLottieRenderContainer,
LazyModalStackProvider,
LazyPWAPrompt,
LazyReloadPrompt,
}

View File

@ -12,6 +12,7 @@ import { Suspense } from "react"
import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3"
import { HotkeysProvider } from "react-hotkeys-hook"
import { ModalStackProvider } from "~/components/ui/modal"
import { HotKeyScopeMap } from "~/constants/hotkeys"
import { jotaiStore } from "~/lib/jotai"
import { persistConfig, queryClient } from "~/lib/query-client"
@ -24,7 +25,6 @@ import {
LazyExtensionExposeProvider,
LazyExternalJumpInProvider,
LazyLottieRenderContainer,
LazyModalStackProvider,
LazyPWAPrompt,
LazyReloadPrompt,
// specific import should add `index` postfix
@ -39,40 +39,41 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
useEnterprise={true}
useRecaptchaNet={true}
>
<MotionProvider>
<PersistQueryClientProvider persistOptions={persistConfig} client={queryClient}>
<HotkeysProvider initiallyActiveScopes={HotKeyScopeMap.Home}>
<Provider store={jotaiStore}>
<Provider store={jotaiStore}>
<MotionProvider>
<PersistQueryClientProvider persistOptions={persistConfig} client={queryClient}>
<HotkeysProvider initiallyActiveScopes={HotKeyScopeMap.Home}>
<I18nProvider>
<Toaster />
<EventProvider />
<ModalStackProvider>
<Toaster />
<EventProvider />
<UserProvider />
<ServerConfigsProvider />
<UserProvider />
<ServerConfigsProvider />
<StableRouterProvider />
<SettingSync />
<FollowCommandManager />
{import.meta.env.DEV && <Devtools />}
<StableRouterProvider />
<SettingSync />
<FollowCommandManager />
{import.meta.env.DEV && <Devtools />}
{children}
{children}
<Suspense>
<LazyExtensionExposeProvider />
<LazyModalStackProvider />
<LazyContextMenuProvider />
<LazyLottieRenderContainer />
<LazyExternalJumpInProvider />
<LazyReloadPrompt />
{!window.__RN__ && <LazyPWAPrompt />}
</Suspense>
<Suspense>
<LazyExtensionExposeProvider />
<LazyContextMenuProvider />
<LazyLottieRenderContainer />
<LazyExternalJumpInProvider />
<LazyReloadPrompt />
{!window.__RN__ && <LazyPWAPrompt />}
</Suspense>
</ModalStackProvider>
</I18nProvider>
</Provider>
</HotkeysProvider>
</HotkeysProvider>
<InvalidateQueryProvider />
</PersistQueryClientProvider>
</MotionProvider>
<InvalidateQueryProvider />
</PersistQueryClientProvider>
</MotionProvider>
</Provider>
</GoogleReCaptchaProvider>
)