From aad5eba2edb66039d123e9e60e484b55d9a037df Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 10 Aug 2024 17:50:57 +0800 Subject: [PATCH] feat: modal resize and draggable to absolute position Signed-off-by: Innei --- .../src/components/ui/modal/stacked/hooks.tsx | 43 +- .../src/components/ui/modal/stacked/modal.tsx | 552 +++++++++--------- .../src/components/ui/modal/stacked/types.tsx | 1 + .../src/modules/settings/modal/hooks.ts | 10 +- .../src/modules/settings/modal/layout.tsx | 135 +++-- 5 files changed, 412 insertions(+), 329 deletions(-) diff --git a/src/renderer/src/components/ui/modal/stacked/hooks.tsx b/src/renderer/src/components/ui/modal/stacked/hooks.tsx index 18cc494a1a..827e1a7305 100644 --- a/src/renderer/src/components/ui/modal/stacked/hooks.tsx +++ b/src/renderer/src/components/ui/modal/stacked/hooks.tsx @@ -1,6 +1,8 @@ import { getUISettings } from "@renderer/atoms/settings/ui" import { jotaiStore } from "@renderer/lib/jotai" -import { useCallback, useContext, useId, useRef } from "react" +import { useCallback, useContext, useId, useRef, useState } from "react" +import { flushSync } from "react-dom" +import { useEventCallback } from "usehooks-ts" import { modalStackAtom } from "./atom" import { CurrentModalContext } from "./context" @@ -73,3 +75,42 @@ const actions = { } export const useCurrentModal = () => useContext(CurrentModalContext) + +export const useResizeableModal = ( + modalElementRef: React.RefObject, + { + enableResizeable, + }: { + enableResizeable: boolean + }, +) => { + const [resizeableStyle, setResizeableStyle] = useState( + {} as React.CSSProperties, + ) + const [isResizeable, setIsResizeable] = useState(false) + + const handlePointDown = useEventCallback(() => { + if (!enableResizeable) return + if (isResizeable) return + const $modalElement = modalElementRef.current + if (!$modalElement) return + + const rect = $modalElement.getBoundingClientRect() + const { x, y } = rect + + flushSync(() => { + setIsResizeable(true) + setResizeableStyle({ + position: "fixed", + top: `${y}px`, + left: `${x}px`, + }) + }) + }) + + return { + resizeableStyle, + isResizeable, + handlePointDown, + } +} diff --git a/src/renderer/src/components/ui/modal/stacked/modal.tsx b/src/renderer/src/components/ui/modal/stacked/modal.tsx index d8a64b00a5..5ff41dc51f 100644 --- a/src/renderer/src/components/ui/modal/stacked/modal.tsx +++ b/src/renderer/src/components/ui/modal/stacked/modal.tsx @@ -10,7 +10,11 @@ import { useAnimationControls, useDragControls } from "framer-motion" import { produce } from "immer" import { useSetAtom } from "jotai" import { Resizable } from "re-resizable" -import type { PointerEventHandler, SyntheticEvent } from "react" +import type { + PointerEventHandler, + PropsWithChildren, + SyntheticEvent, +} from "react" import { createElement, forwardRef, @@ -18,6 +22,7 @@ import { memo, useCallback, useEffect, + useImperativeHandle, useMemo, useRef, useState, @@ -30,297 +35,322 @@ import { modalStackAtom } from "./atom" import { MODAL_STACK_Z_INDEX, modalMontionConfig } from "./constants" import type { CurrentModalContentProps, ModalActionsInternal } from "./context" import { CurrentModalContext } from "./context" +import { useResizeableModal } from "./hooks" import type { ModalProps } from "./types" -export const ModalInternal: Component<{ - item: ModalProps & { id: string } - index: number +export const ModalInternal = memo( + forwardRef< + HTMLDivElement, + { + item: ModalProps & { id: string } + index: number - isTop: boolean - onClose?: (open: boolean) => void -}> = memo( - forwardRef(function Modal( - { item, index, onClose: onPropsClose, children, isTop }, - ref: any, - ) { - const { - CustomModalComponent, - modalClassName, - content, - title, - clickOutsideToDismiss, - modalContainerClassName, - wrapper: Wrapper = Fragment, - max, - icon, - canClose = true, + isTop: boolean + onClose?: (open: boolean) => void + } & PropsWithChildren + >(function Modal( + { item, index, onClose: onPropsClose, children, isTop }, + ref, + ) { + const { + CustomModalComponent, + modalClassName, + content, + title, + clickOutsideToDismiss, + modalContainerClassName, + wrapper: Wrapper = Fragment, + max, + icon, + canClose = true, - draggable = false, - resizeable = false, - resizeDefaultSize, - } = item + draggable = false, + resizeable = false, + resizeDefaultSize, + } = item - const setStack = useSetAtom(modalStackAtom) + const setStack = useSetAtom(modalStackAtom) - const [currentIsClosing, setCurrentIsClosing] = useState(false) + const [currentIsClosing, setCurrentIsClosing] = useState(false) - const close = useEventCallback((forceClose = false) => { - if (!canClose && !forceClose) return - setCurrentIsClosing(true) - nextFrame(() => { - setStack((p) => p.filter((modal) => modal.id !== item.id)) - }) - onPropsClose?.(false) - }) + const close = useEventCallback((forceClose = false) => { + if (!canClose && !forceClose) return + setCurrentIsClosing(true) + nextFrame(() => { + setStack((p) => p.filter((modal) => modal.id !== item.id)) + }) + onPropsClose?.(false) + }) - const onClose = useCallback( - (open: boolean): void => { - if (!open) { - close() - } - }, - [close], - ) + const onClose = useCallback( + (open: boolean): void => { + if (!open) { + close() + } + }, + [close], + ) - const opaque = useUISettingKey("modalOpaque") + const opaque = useUISettingKey("modalOpaque") - const zIndexStyle = useMemo( - () => ({ zIndex: MODAL_STACK_Z_INDEX + index + 1 }), - [index], - ) - const dismiss = useCallback( - (e: SyntheticEvent) => { - e.stopPropagation() + const zIndexStyle = useMemo( + () => ({ zIndex: MODAL_STACK_Z_INDEX + index + 1 }), + [index], + ) + const dismiss = useCallback( + (e: SyntheticEvent) => { + e.stopPropagation() - close(true) - }, - [close], - ) + close(true) + }, + [close], + ) - const animateController = useAnimationControls() - useEffect(() => { - requestAnimationFrame(() => { - animateController.start(modalMontionConfig.animate) - }) - }, [animateController]) - const noticeModal = useCallback(() => { - animateController - .start({ - scale: 1.05, - transition: { - duration: 0.06, - }, - }) - .then(() => { - animateController.start({ - scale: 1, + const modalElementRef = useRef(null) + + const { + handlePointDown: handleResizeEnable, + isResizeable, + resizeableStyle, + } = useResizeableModal(modalElementRef, { + enableResizeable: resizeable, }) - }) - }, [animateController]) + const animateController = useAnimationControls() + useEffect(() => { + requestAnimationFrame(() => { + animateController.start(modalMontionConfig.animate) + }) + }, [animateController]) + const noticeModal = useCallback(() => { + animateController + .start({ + scale: 1.05, + transition: { + duration: 0.06, + }, + }) + .then(() => { + animateController.start({ + scale: 1, + }) + }) + }, [animateController]) - const dragController = useDragControls() - const handleDrag: PointerEventHandler = useCallback( - (e) => { - if (draggable) { - dragController.start(e) - } - }, - [dragController, draggable], - ) + const dragController = useDragControls() + const handleDrag: PointerEventHandler = useCallback( + (e) => { + if (draggable) { + dragController.start(e) + } + }, + [dragController, draggable], + ) - useEffect(() => { - if (isTop) return - animateController.start({ - scale: 0.96, - y: 10, - }) - return () => { - try { - animateController.stop() - animateController.start({ - scale: 1, - y: 0, - }) - } catch { - /* empty */ - } - } - }, [isTop]) + useEffect(() => { + if (isTop) return + animateController.start({ + scale: 0.96, + y: 10, + }) + return () => { + try { + animateController.stop() + animateController.start({ + scale: 1, + y: 0, + }) + } catch { + /* empty */ + } + } + }, [isTop]) - const modalContentRef = useRef(null) - const ModalProps: ModalActionsInternal = useMemo( - () => ({ - dismiss: close, - setClickOutSideToDismiss: (v) => { - setStack((state) => - produce(state, (draft) => { - const model = draft.find((modal) => modal.id === item.id) - if (!model) return - if (model.clickOutsideToDismiss === v) return - model.clickOutsideToDismiss = v + const modalContentRef = useRef(null) + const ModalProps: ModalActionsInternal = useMemo( + () => ({ + dismiss: close, + setClickOutSideToDismiss: (v) => { + setStack((state) => + produce(state, (draft) => { + const model = draft.find((modal) => modal.id === item.id) + if (!model) return + if (model.clickOutsideToDismiss === v) return + model.clickOutsideToDismiss = v + }), + ) + }, }), + [close, item.id, setStack], ) - }, - }), - [close, item.id, setStack], - ) - const ModalContextProps = useMemo( - () => ({ - ...ModalProps, - ref: modalContentRef, - }), - [ModalProps], - ) + const ModalContextProps = useMemo( + () => ({ + ...ModalProps, + ref: modalContentRef, + }), + [ModalProps], + ) - const edgeElementRef = useRef(null) + const edgeElementRef = useRef(null) - const finalChildren = useMemo( - () => ( - - - - {children ?? createElement(content, ModalProps)} - - - - ), - [ModalContextProps, ModalProps, children, content], - ) + const finalChildren = useMemo( + () => ( + + + + {children ?? createElement(content, ModalProps)} + + + + ), + [ModalContextProps, ModalProps, children, content], + ) - useEffect(() => { - if (currentIsClosing) { - // Radix dialog will block pointer events - document.body.style.pointerEvents = "auto" - } - }, [currentIsClosing]) + useEffect(() => { + if (currentIsClosing) { + // Radix dialog will block pointer events + document.body.style.pointerEvents = "auto" + } + }, [currentIsClosing]) - const switchHotkeyScope = useSwitchHotKeyScope() - useEffect(() => { - switchHotkeyScope("Modal") - return () => { - switchHotkeyScope("Home") - } - }, [switchHotkeyScope]) + const switchHotkeyScope = useSwitchHotKeyScope() + useEffect(() => { + switchHotkeyScope("Modal") + return () => { + switchHotkeyScope("Home") + } + }, [switchHotkeyScope]) - if (CustomModalComponent) { - return ( - - - - - {title} - - -
-
- {finalChildren} -
-
-
-
-
-
- ) - } + const modalStyle = useMemo( + () => ({ ...zIndexStyle, ...resizeableStyle }), + [resizeableStyle, zIndexStyle], + ) - const ResizeSwitch = resizeable ? Resizable : Fragment + useImperativeHandle(ref, () => modalElementRef.current!) + if (CustomModalComponent) { + return ( + + + + + {title} + + +
+
+ {finalChildren} +
+
+
+
+
+
+ ) + } - return ( - - - - -
- - + return ( + + + +
- - {icon && {icon}} + {title} - - {canClose && ( - + - - - )} -
- +
+ + {icon && {icon}} -
- {finalChildren} + {title} + + {canClose && ( + + + + )} +
+ + +
+ {finalChildren} +
+ +
-
-
-
-
-
-
-
- ) - }), + + + + + ) + }), ) diff --git a/src/renderer/src/components/ui/modal/stacked/types.tsx b/src/renderer/src/components/ui/modal/stacked/types.tsx index cf378c8fdc..93055c4a1f 100644 --- a/src/renderer/src/components/ui/modal/stacked/types.tsx +++ b/src/renderer/src/components/ui/modal/stacked/types.tsx @@ -7,6 +7,7 @@ export interface ModalProps { icon?: ReactNode CustomModalComponent?: FC + content: FC clickOutsideToDismiss?: boolean modalClassName?: string diff --git a/src/renderer/src/modules/settings/modal/hooks.ts b/src/renderer/src/modules/settings/modal/hooks.ts index 7d795f87dd..1feadfaae3 100644 --- a/src/renderer/src/modules/settings/modal/hooks.ts +++ b/src/renderer/src/modules/settings/modal/hooks.ts @@ -1,4 +1,5 @@ import { useModalStack } from "@renderer/components/ui/modal/stacked/hooks" +import { NoopChildren } from "@renderer/components/ui/modal/stacked/utils" import { createElement, useCallback } from "react" import { SettingModalContent } from "./content" @@ -15,14 +16,7 @@ export const useSettingModal = () => { createElement(SettingModalContent, { initialTab, }), - CustomModalComponent: (props) => - createElement( - "div", - { - className: "center h-full center", - }, - props.children, - ), + CustomModalComponent: NoopChildren, modalContainerClassName: "overflow-hidden", }), [present], diff --git a/src/renderer/src/modules/settings/modal/layout.tsx b/src/renderer/src/modules/settings/modal/layout.tsx index 8672888589..400c270986 100644 --- a/src/renderer/src/modules/settings/modal/layout.tsx +++ b/src/renderer/src/modules/settings/modal/layout.tsx @@ -1,12 +1,13 @@ import { useUISettingSelector } from "@renderer/atoms/settings/ui" import { m } from "@renderer/components/common/Motion" import { Logo } from "@renderer/components/icons/logo" +import { useResizeableModal } from "@renderer/components/ui/modal" import { preventDefault } from "@renderer/lib/dom" import { cn } from "@renderer/lib/utils" import { useDragControls } from "framer-motion" import { Resizable } from "re-resizable" import type { PointerEventHandler, PropsWithChildren } from "react" -import { useCallback, useEffect } from "react" +import { useCallback, useEffect, useRef } from "react" import { settings } from "../constants" import { SettingsSidebarTitle } from "../title" @@ -20,6 +21,13 @@ export function SettingModalLayout( const { children, initialTab } = props const setTab = useSetSettingTab() const tab = useSettingTab() + const elementRef = useRef(null) + const { handlePointDown, isResizeable, resizeableStyle } = useResizeableModal( + elementRef, + { + enableResizeable: true, + }, + ) useEffect(() => { if (!tab) { @@ -40,73 +48,82 @@ export function SettingModalLayout( (e) => { if (draggable) { dragController.start(e) + handlePointDown() } }, - [dragController, draggable], + [dragController, draggable, handlePointDown], ) return ( - - + - {draggable && ( + + {draggable && ( +
+ )}
- )} -
-
-
- - {APP_NAME} + className="flex h-0 flex-1 bg-theme-modal-background-opaque" + ref={elementRef} + > +
+
+ + {APP_NAME} +
+ {settings.map((t) => ( + + ))} +
+
+ {children}
- {settings.map((t) => ( - - ))} -
-
- {children}
-
- - + + +
) }