feat(ai): implement fixed and floating chat panel styles
- Introduced a new enum for AI chat panel styles, allowing users to toggle between fixed and floating layouts. - Added state management for the floating panel dimensions and position, ensuring it remains anchored during window resizing. - Updated various components to utilize the new panel style settings, enhancing the user experience with improved layout flexibility. - Implemented UI elements for toggling between fixed and floating modes in the chat interface. These changes collectively enhance the functionality and usability of the AI chat interface, providing users with more control over their layout preferences. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
3d577699fc
commit
ab537447e8
|
|
@ -19,9 +19,38 @@ export const {
|
|||
export const aiServerSyncWhiteListKeys = []
|
||||
// Local Setting for ai
|
||||
|
||||
const aiChatPinnedAtom = atom<boolean>(true)
|
||||
export const useAIChatPinned = () => useAtomValue(aiChatPinnedAtom)
|
||||
export const setAIChatPinned = (pinned: boolean) => {
|
||||
jotaiStore.set(aiChatPinnedAtom, pinned)
|
||||
export enum AIChatPanelStyle {
|
||||
Fixed = "fixed",
|
||||
Floating = "floating",
|
||||
}
|
||||
export const getAIChatPinned = () => jotaiStore.get(aiChatPinnedAtom)
|
||||
|
||||
const aiChatPanelStyleAtom = atom<AIChatPanelStyle>(AIChatPanelStyle.Floating)
|
||||
export const useAIChatPanelStyle = () => useAtomValue(aiChatPanelStyleAtom)
|
||||
export const setAIChatPanelStyle = (style: AIChatPanelStyle) => {
|
||||
jotaiStore.set(aiChatPanelStyleAtom, style)
|
||||
}
|
||||
export const getAIChatPanelStyle = () => jotaiStore.get(aiChatPanelStyleAtom)
|
||||
|
||||
// Floating panel state atoms
|
||||
interface FloatingPanelState {
|
||||
width: number
|
||||
height: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const defaultFloatingPanelState: FloatingPanelState = {
|
||||
width: 500,
|
||||
height: 600,
|
||||
x: window.innerWidth - 520, // 20px margin from right
|
||||
y: window.innerHeight - 620, // 20px margin from bottom
|
||||
}
|
||||
|
||||
const floatingPanelStateAtom = atom<FloatingPanelState>(defaultFloatingPanelState)
|
||||
|
||||
export const useFloatingPanelState = () => useAtomValue(floatingPanelStateAtom)
|
||||
export const setFloatingPanelState = (state: Partial<FloatingPanelState>) => {
|
||||
const currentState = jotaiStore.get(floatingPanelStateAtom)
|
||||
jotaiStore.set(floatingPanelStateAtom, { ...currentState, ...state })
|
||||
}
|
||||
export const getFloatingPanelState = () => jotaiStore.get(floatingPanelStateAtom)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import {
|
|||
setReadabilityStatus,
|
||||
useEntryIsInReadability,
|
||||
} from "~/atoms/readability"
|
||||
import { useAIChatPinned } from "~/atoms/settings/ai"
|
||||
import { useIntegrationSettingValue } from "~/atoms/settings/integration"
|
||||
import { useShowSourceContent } from "~/atoms/source-content"
|
||||
import { ipcServices } from "~/lib/client"
|
||||
|
|
@ -31,7 +30,6 @@ import { useCommandShortcuts } from "~/modules/command/hooks/use-command-binding
|
|||
import type { FollowCommandId } from "~/modules/command/types"
|
||||
import { useToolbarOrderMap } from "~/modules/customize-toolbar/hooks"
|
||||
|
||||
import { useFeature } from "./useFeature"
|
||||
import { useRouteParams } from "./useRouteParams"
|
||||
|
||||
export const enableEntryReadability = async ({ id, url }: { id: string; url: string }) => {
|
||||
|
|
@ -249,8 +247,7 @@ export const useEntryActions = ({
|
|||
const isShowAISummaryOnce = useShowAISummaryOnce()
|
||||
const isShowAITranslationAuto = useShowAITranslationAuto(!!entry?.translation)
|
||||
const isShowAITranslationOnce = useShowAITranslationOnce()
|
||||
const isShowAIChatPinned = useAIChatPinned()
|
||||
const aiEnabled = useFeature("ai")
|
||||
|
||||
const runCmdFn = useRunCommandFn()
|
||||
const hasEntry = !!entry
|
||||
|
||||
|
|
@ -441,14 +438,6 @@ export const useEntryActions = ({
|
|||
entryId,
|
||||
}),
|
||||
|
||||
new EntryActionMenuItem({
|
||||
id: COMMAND_ID.global.toggleAIChatPinned,
|
||||
onClick: runCmdFn(COMMAND_ID.global.toggleAIChatPinned, [{ entryId }]),
|
||||
entryId,
|
||||
active: isShowAIChatPinned,
|
||||
hide: !aiEnabled,
|
||||
}),
|
||||
|
||||
// Custom Integration with sub-menu
|
||||
...(() => {
|
||||
const customIntegrations = integrationSettings.customIntegration || []
|
||||
|
|
@ -513,8 +502,7 @@ export const useEntryActions = ({
|
|||
isCollection,
|
||||
compact,
|
||||
isEntryInReadability,
|
||||
isShowAIChatPinned,
|
||||
aiEnabled,
|
||||
|
||||
integrationSettings.customIntegration,
|
||||
integrationSettings.enableCustomIntegration,
|
||||
])
|
||||
|
|
|
|||
|
|
@ -54,8 +54,7 @@ export const ChatHeader = () => {
|
|||
/>
|
||||
|
||||
<div className="relative z-10 flex h-full items-center justify-between px-4">
|
||||
{/* Left side - Title */}
|
||||
<div className="mr-2 min-w-0 flex-1">
|
||||
<div className="mr-2 flex min-w-0 flex-1 items-center">
|
||||
<EditableTitle
|
||||
title={currentTitle}
|
||||
onSave={handleTitleSave}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useCallback, useState } from "react"
|
|||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { AIChatPanelStyle, setAIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
|
||||
import { RelativeDay } from "~/components/ui/datetime"
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -23,6 +24,7 @@ import { downloadMarkdown, exportChatToMarkdown } from "~/modules/ai-chat/utils/
|
|||
export const ChatMoreDropdown = () => {
|
||||
const currentTitle = useCurrentTitle()
|
||||
const currentChatId = useCurrentChatId()
|
||||
const panelStyle = useAIChatPanelStyle()
|
||||
|
||||
const chatActions = useChatActions()
|
||||
const { t } = useTranslation("ai")
|
||||
|
|
@ -91,6 +93,12 @@ export const ChatMoreDropdown = () => {
|
|||
}
|
||||
}, [chatActions, currentTitle, t])
|
||||
|
||||
const handleToggleMode = useCallback(() => {
|
||||
const newStyle =
|
||||
panelStyle === AIChatPanelStyle.Fixed ? AIChatPanelStyle.Floating : AIChatPanelStyle.Fixed
|
||||
setAIChatPanelStyle(newStyle)
|
||||
}, [panelStyle])
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={handleDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -169,6 +177,19 @@ export const ChatMoreDropdown = () => {
|
|||
<i className="i-mgc-download-2-cute-re mr-2 size-4" />
|
||||
<span>Export Chat</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={handleToggleMode}>
|
||||
<i
|
||||
className={`mr-2 size-4 ${panelStyle === AIChatPanelStyle.Fixed ? "i-mingcute-rectangle-vertical-line" : "i-mingcute-pin-line"}`}
|
||||
/>
|
||||
<span>
|
||||
{panelStyle === AIChatPanelStyle.Fixed
|
||||
? "Switch to Floating Panel"
|
||||
: "Switch to Fixed Panel"}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { cn } from "@follow/utils"
|
||||
import type { FC } from "react"
|
||||
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { ChatHeader } from "~/modules/ai-chat/components/layouts/ChatHeader"
|
||||
import { ChatInterface } from "~/modules/ai-chat/components/layouts/ChatInterface"
|
||||
|
||||
export interface AIChatFixedPanelProps
|
||||
extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {}
|
||||
|
||||
export const AIChatFixedPanel: FC<AIChatFixedPanelProps> = ({ className, ...props }) => {
|
||||
return (
|
||||
<Focusable
|
||||
scope={HotkeyScope.AIChat}
|
||||
className={cn("bg-background relative flex h-full flex-col overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChatHeader />
|
||||
<ChatInterface />
|
||||
</Focusable>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import { cn, computeAdjustedTopLeftPosition } from "@follow/utils"
|
||||
import { m } from "motion/react"
|
||||
import type { ResizeDirection } from "re-resizable"
|
||||
import { Resizable } from "re-resizable"
|
||||
import type { FC } from "react"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import {
|
||||
getFloatingPanelState,
|
||||
setFloatingPanelState,
|
||||
useFloatingPanelState,
|
||||
} from "~/atoms/settings/ai"
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { ChatHeader } from "~/modules/ai-chat/components/layouts/ChatHeader"
|
||||
import { ChatInterface } from "~/modules/ai-chat/components/layouts/ChatInterface"
|
||||
|
||||
export interface AIChatFloatingPanelProps
|
||||
extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {}
|
||||
|
||||
export const AIChatFloatingPanel: FC<AIChatFloatingPanelProps> = ({ className, ...props }) => {
|
||||
const floatingState = useFloatingPanelState()
|
||||
|
||||
// Preserve right/bottom margins to keep panel anchored to bottom-right on window resize
|
||||
const rightBottomMarginRef = useRef({ right: 0, bottom: 0 })
|
||||
useEffect(() => {
|
||||
rightBottomMarginRef.current = {
|
||||
right: Math.max(0, window.innerWidth - (floatingState.x + floatingState.width)),
|
||||
bottom: Math.max(0, window.innerHeight - (floatingState.y + floatingState.height)),
|
||||
}
|
||||
}, [floatingState.x, floatingState.y, floatingState.width, floatingState.height])
|
||||
|
||||
const handleResize = useRef(
|
||||
(_event: MouseEvent | TouchEvent, direction: ResizeDirection, ref: HTMLElement) => {
|
||||
const prev = getFloatingPanelState()
|
||||
const newWidth = ref.offsetWidth
|
||||
const newHeight = ref.offsetHeight
|
||||
const { x, y } = computeAdjustedTopLeftPosition(
|
||||
{ x: prev.x, y: prev.y, width: prev.width, height: prev.height },
|
||||
{ width: newWidth, height: newHeight },
|
||||
direction,
|
||||
)
|
||||
|
||||
setFloatingPanelState({ width: newWidth, height: newHeight, x, y })
|
||||
},
|
||||
).current
|
||||
|
||||
// Keep floating panel anchored to bottom-right on window resize
|
||||
useEffect(() => {
|
||||
const handleWindowResize = () => {
|
||||
const { right, bottom } = rightBottomMarginRef.current
|
||||
const newX = Math.max(0, window.innerWidth - floatingState.width - right)
|
||||
const newY = Math.max(0, window.innerHeight - floatingState.height - bottom)
|
||||
setFloatingPanelState({ x: newX, y: newY })
|
||||
}
|
||||
|
||||
window.addEventListener("resize", handleWindowResize)
|
||||
return () => window.removeEventListener("resize", handleWindowResize)
|
||||
}, [floatingState.width, floatingState.height])
|
||||
|
||||
const chatContent = (
|
||||
<Focusable
|
||||
scope={HotkeyScope.AIChat}
|
||||
className={cn(
|
||||
"bg-background relative flex h-full flex-col overflow-hidden rounded-lg border shadow-xl",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChatHeader />
|
||||
<ChatInterface />
|
||||
</Focusable>
|
||||
)
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="fixed z-50"
|
||||
style={{ left: floatingState.x, top: floatingState.y }}
|
||||
>
|
||||
<div className="relative">
|
||||
<Resizable
|
||||
size={{ width: floatingState.width, height: floatingState.height }}
|
||||
onResize={handleResize}
|
||||
onResizeStop={handleResize}
|
||||
minWidth={500}
|
||||
minHeight={600}
|
||||
maxWidth={600}
|
||||
maxHeight={Math.floor(window.innerHeight * 0.85)}
|
||||
enable={{
|
||||
top: true,
|
||||
right: true,
|
||||
bottom: true,
|
||||
left: true,
|
||||
topRight: true,
|
||||
bottomRight: true,
|
||||
bottomLeft: true,
|
||||
topLeft: true,
|
||||
}}
|
||||
>
|
||||
{chatContent}
|
||||
</Resizable>
|
||||
</div>
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,21 +1,18 @@
|
|||
import type { FC } from "react"
|
||||
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { ChatHeader } from "~/modules/ai-chat/components/layouts/ChatHeader"
|
||||
import { ChatInterface } from "~/modules/ai-chat/components/layouts/ChatInterface"
|
||||
import { AIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
|
||||
|
||||
export const AIChatLayout: FC<
|
||||
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
|
||||
> = ({ ...props }) => {
|
||||
return (
|
||||
<Focusable
|
||||
scope={HotkeyScope.AIChat}
|
||||
className="bg-background relative flex h-full flex-col overflow-hidden"
|
||||
{...props}
|
||||
>
|
||||
<ChatHeader />
|
||||
<ChatInterface />
|
||||
</Focusable>
|
||||
)
|
||||
import { AIChatFixedPanel } from "./AIChatFixedPanel"
|
||||
import { AIChatFloatingPanel } from "./AIChatFloatingPanel"
|
||||
|
||||
export interface AIChatLayoutProps
|
||||
extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {}
|
||||
|
||||
export const AIChatLayout: FC<AIChatLayoutProps> = ({ ...props }) => {
|
||||
const panelStyle = useAIChatPanelStyle()
|
||||
|
||||
if (panelStyle === AIChatPanelStyle.Floating) {
|
||||
return <AIChatFloatingPanel {...props} />
|
||||
}
|
||||
return <AIChatFixedPanel {...props} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useMemo } from "react"
|
|||
import { useResizable } from "react-resizable-layout"
|
||||
import { useParams } from "react-router"
|
||||
|
||||
import { useAIChatPinned } from "~/atoms/settings/ai"
|
||||
import { AIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
|
||||
import { useRealInWideMode } from "~/atoms/settings/ui"
|
||||
import { useTimelineColumnShow, useTimelineColumnTempShow } from "~/atoms/sidebar"
|
||||
import { m } from "~/components/common/Motion"
|
||||
|
|
@ -102,7 +102,8 @@ const Grid = ({ entryId }) => {
|
|||
const wideMode = !!(settingWideMode && entryId)
|
||||
const feedColumnTempShow = useTimelineColumnTempShow()
|
||||
const feedColumnShow = useTimelineColumnShow()
|
||||
const aiPinned = useAIChatPinned()
|
||||
const panelStyle = useAIChatPanelStyle()
|
||||
const aiPinned = panelStyle === AIChatPanelStyle.Fixed
|
||||
const shouldHeaderPaddingLeft = feedColumnTempShow && !feedColumnShow && settingWideMode
|
||||
|
||||
const { isDragging, position, separatorProps, separatorCursor } = useResizable({
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ const MAIN_ACTIONS = [
|
|||
|
||||
COMMAND_ID.entry.toggleAISummary,
|
||||
COMMAND_ID.entry.readability,
|
||||
COMMAND_ID.global.toggleAIChatPinned,
|
||||
|
||||
COMMAND_ID.entry.viewSourceContent,
|
||||
COMMAND_ID.entry.share,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||
import { useResizable } from "react-resizable-layout"
|
||||
import { useParams } from "react-router"
|
||||
|
||||
import { AIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
|
||||
import { getUISettings, setUISetting } from "~/atoms/settings/ui"
|
||||
import { ROUTE_ENTRY_PENDING } from "~/constants"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
|
|
@ -21,6 +22,7 @@ import { EntryColumn } from "./index"
|
|||
const AIEntryLayoutImpl = () => {
|
||||
const { entryId } = useParams()
|
||||
const navigate = useNavigateEntry()
|
||||
const panelStyle = useAIChatPanelStyle()
|
||||
|
||||
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
|
||||
|
||||
|
|
@ -130,7 +132,7 @@ const AIEntryLayoutImpl = () => {
|
|||
|
||||
return (
|
||||
<div className="relative flex min-w-0 grow">
|
||||
<div className="h-full flex-1 border-r">
|
||||
<div className={cn("h-full flex-1", panelStyle === AIChatPanelStyle.Fixed && "border-r")}>
|
||||
<AppLayoutGridContainerProvider>
|
||||
<div className="relative h-full">
|
||||
{/* Entry list - always rendered to prevent animation */}
|
||||
|
|
@ -175,20 +177,29 @@ const AIEntryLayoutImpl = () => {
|
|||
</div>
|
||||
</AppLayoutGridContainerProvider>
|
||||
</div>
|
||||
<PanelSplitter
|
||||
{...separatorProps}
|
||||
cursor={separatorCursor}
|
||||
isDragging={isDragging}
|
||||
onDoubleClick={() => {
|
||||
setUISetting("aiColWidth", defaultUISettings.aiColWidth)
|
||||
setPosition(defaultUISettings.aiColWidth)
|
||||
}}
|
||||
/>
|
||||
<AIChatLayout
|
||||
style={
|
||||
{ width: position, "--ai-chat-layout-width": `${position}px` } as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Fixed panel layout */}
|
||||
{panelStyle === AIChatPanelStyle.Fixed && (
|
||||
<>
|
||||
<PanelSplitter
|
||||
{...separatorProps}
|
||||
cursor={separatorCursor}
|
||||
isDragging={isDragging}
|
||||
onDoubleClick={() => {
|
||||
setUISetting("aiColWidth", defaultUISettings.aiColWidth)
|
||||
setPosition(defaultUISettings.aiColWidth)
|
||||
}}
|
||||
/>
|
||||
<AIChatLayout
|
||||
style={
|
||||
{ width: position, "--ai-chat-layout-width": `${position}px` } as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Floating panel - renders outside layout flow */}
|
||||
{panelStyle === AIChatPanelStyle.Floating && <AIChatLayout />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { cn } from "@follow/utils/utils"
|
|||
import { useStore } from "jotai"
|
||||
import { memo, useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { useAIChatPinned } from "~/atoms/settings/ai"
|
||||
import { AIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
|
||||
import type { TocRef } from "~/components/ui/markdown/components/Toc"
|
||||
import { Toc } from "~/components/ui/markdown/components/Toc"
|
||||
import { useFeature } from "~/hooks/biz/useFeature"
|
||||
|
|
@ -51,7 +51,8 @@ const BackTopIndicator: Component = memo(({ className }) => {
|
|||
const scrollElement = useScrollViewElement()
|
||||
const aiEnabled = useFeature("ai")
|
||||
|
||||
const isAiPanelOpen = useAIChatPinned()
|
||||
const panelStyle = useAIChatPanelStyle()
|
||||
const isAiPanelOpen = panelStyle === AIChatPanelStyle.Fixed
|
||||
|
||||
return (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@ export * from "./jotai"
|
|||
export * from "./noop"
|
||||
export * from "./path-parser"
|
||||
export * from "./react"
|
||||
export * from "./resize"
|
||||
export * from "./url-for-video"
|
||||
export * from "./utils"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
export interface ViewportSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface RectLike {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute new top-left position when resizing from top and/or left so that the
|
||||
* opposite corner remains visually anchored. The result is clamped to viewport.
|
||||
*/
|
||||
export function computeAdjustedTopLeftPosition(
|
||||
previous: RectLike,
|
||||
newSize: { width: number; height: number },
|
||||
direction: string,
|
||||
viewport?: ViewportSize,
|
||||
): { x: number; y: number } {
|
||||
viewport ??= { width: window.innerWidth, height: window.innerHeight }
|
||||
const resizedFromLeft = /left/i.test(direction)
|
||||
const resizedFromTop = /top/i.test(direction)
|
||||
|
||||
let newX = previous.x
|
||||
let newY = previous.y
|
||||
|
||||
if (resizedFromLeft) {
|
||||
newX = previous.x - (newSize.width - previous.width)
|
||||
}
|
||||
if (resizedFromTop) {
|
||||
newY = previous.y - (newSize.height - previous.height)
|
||||
}
|
||||
|
||||
const maxX = Math.max(0, viewport.width - newSize.width)
|
||||
const maxY = Math.max(0, viewport.height - newSize.height)
|
||||
newX = Math.min(Math.max(0, newX), maxX)
|
||||
newY = Math.min(Math.max(0, newY), maxY)
|
||||
|
||||
return { x: newX, y: newY }
|
||||
}
|
||||
Loading…
Reference in New Issue