2025.11.28.22.40
This commit is contained in:
47
src/hooks/use-composed-ref.ts
Normal file
47
src/hooks/use-composed-ref.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useRef } from "react"
|
||||
|
||||
// basically Exclude<React.ClassAttributes<T>["ref"], string>
|
||||
type UserRef<T> =
|
||||
| ((instance: T | null) => void)
|
||||
| React.RefObject<T | null>
|
||||
| null
|
||||
| undefined
|
||||
|
||||
const updateRef = <T>(ref: NonNullable<UserRef<T>>, value: T | null) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(value)
|
||||
} else if (ref && typeof ref === "object" && "current" in ref) {
|
||||
// Safe assignment without MutableRefObject
|
||||
;(ref as { current: T | null }).current = value
|
||||
}
|
||||
}
|
||||
|
||||
export const useComposedRef = <T extends HTMLElement>(
|
||||
libRef: React.RefObject<T | null>,
|
||||
userRef: UserRef<T>
|
||||
) => {
|
||||
const prevUserRef = useRef<UserRef<T>>(null)
|
||||
|
||||
return useCallback(
|
||||
(instance: T | null) => {
|
||||
if (libRef && "current" in libRef) {
|
||||
;(libRef as { current: T | null }).current = instance
|
||||
}
|
||||
|
||||
if (prevUserRef.current) {
|
||||
updateRef(prevUserRef.current, null)
|
||||
}
|
||||
|
||||
prevUserRef.current = userRef
|
||||
|
||||
if (userRef) {
|
||||
updateRef(userRef, instance)
|
||||
}
|
||||
},
|
||||
[libRef, userRef]
|
||||
)
|
||||
}
|
||||
|
||||
export default useComposedRef
|
||||
69
src/hooks/use-cursor-visibility.ts
Normal file
69
src/hooks/use-cursor-visibility.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import { useWindowSize } from "@/hooks/use-window-size"
|
||||
import { useBodyRect } from "@/hooks/use-element-rect"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export interface CursorVisibilityOptions {
|
||||
/**
|
||||
* The Tiptap editor instance
|
||||
*/
|
||||
editor?: Editor | null
|
||||
/**
|
||||
* Reference to the toolbar element that may obscure the cursor
|
||||
*/
|
||||
overlayHeight?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook that ensures the cursor remains visible when typing in a Tiptap editor.
|
||||
* Automatically scrolls the window when the cursor would be hidden by the toolbar.
|
||||
*
|
||||
* @param options.editor The Tiptap editor instance
|
||||
* @param options.overlayHeight Toolbar height to account for
|
||||
* @returns The bounding rect of the body
|
||||
*/
|
||||
export function useCursorVisibility({
|
||||
editor,
|
||||
overlayHeight = 0,
|
||||
}: CursorVisibilityOptions) {
|
||||
const { height: windowHeight } = useWindowSize()
|
||||
const rect = useBodyRect({
|
||||
enabled: true,
|
||||
throttleMs: 100,
|
||||
useResizeObserver: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const ensureCursorVisibility = () => {
|
||||
if (!editor) return
|
||||
|
||||
const { state, view } = editor
|
||||
if (!view.hasFocus()) return
|
||||
|
||||
// Get current cursor position coordinates
|
||||
const { from } = state.selection
|
||||
const cursorCoords = view.coordsAtPos(from)
|
||||
|
||||
if (windowHeight < rect.height && cursorCoords) {
|
||||
const availableSpace = windowHeight - cursorCoords.top
|
||||
|
||||
// If the cursor is hidden behind the overlay or offscreen, scroll it into view
|
||||
if (availableSpace < overlayHeight) {
|
||||
const targetCursorY = Math.max(windowHeight / 2, overlayHeight)
|
||||
const currentScrollY = window.scrollY
|
||||
const cursorAbsoluteY = cursorCoords.top + currentScrollY
|
||||
const newScrollY = cursorAbsoluteY - targetCursorY
|
||||
|
||||
window.scrollTo({
|
||||
top: Math.max(0, newScrollY),
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensureCursorVisibility()
|
||||
}, [editor, overlayHeight, windowHeight, rect.height])
|
||||
|
||||
return rect
|
||||
}
|
||||
166
src/hooks/use-element-rect.ts
Normal file
166
src/hooks/use-element-rect.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useThrottledCallback } from "@/hooks/use-throttled-callback"
|
||||
|
||||
export type RectState = Omit<DOMRect, "toJSON">
|
||||
|
||||
export interface ElementRectOptions {
|
||||
/**
|
||||
* The element to track. Can be an Element, ref, or selector string.
|
||||
* Defaults to document.body if not provided.
|
||||
*/
|
||||
element?: Element | React.RefObject<Element> | string | null
|
||||
/**
|
||||
* Whether to enable rect tracking
|
||||
*/
|
||||
enabled?: boolean
|
||||
/**
|
||||
* Throttle delay in milliseconds for rect updates
|
||||
*/
|
||||
throttleMs?: number
|
||||
/**
|
||||
* Whether to use ResizeObserver for more accurate tracking
|
||||
*/
|
||||
useResizeObserver?: boolean
|
||||
}
|
||||
|
||||
const initialRect: RectState = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
}
|
||||
|
||||
const isSSR = typeof window === "undefined"
|
||||
const hasResizeObserver = !isSSR && typeof ResizeObserver !== "undefined"
|
||||
|
||||
/**
|
||||
* Helper function to check if code is running on client side
|
||||
*/
|
||||
const isClientSide = (): boolean => !isSSR
|
||||
|
||||
/**
|
||||
* Custom hook that tracks an element's bounding rectangle and updates on resize, scroll, etc.
|
||||
*
|
||||
* @param options Configuration options for element rect tracking
|
||||
* @returns The current bounding rectangle of the element
|
||||
*/
|
||||
export function useElementRect({
|
||||
element,
|
||||
enabled = true,
|
||||
throttleMs = 100,
|
||||
useResizeObserver = true,
|
||||
}: ElementRectOptions = {}): RectState {
|
||||
const [rect, setRect] = useState<RectState>(initialRect)
|
||||
|
||||
const getTargetElement = useCallback((): Element | null => {
|
||||
if (!enabled || !isClientSide()) return null
|
||||
|
||||
if (!element) {
|
||||
return document.body
|
||||
}
|
||||
|
||||
if (typeof element === "string") {
|
||||
return document.querySelector(element)
|
||||
}
|
||||
|
||||
if ("current" in element) {
|
||||
return element.current
|
||||
}
|
||||
|
||||
return element
|
||||
}, [element, enabled])
|
||||
|
||||
const updateRect = useThrottledCallback(
|
||||
() => {
|
||||
if (!enabled || !isClientSide()) return
|
||||
|
||||
const targetElement = getTargetElement()
|
||||
if (!targetElement) {
|
||||
setRect(initialRect)
|
||||
return
|
||||
}
|
||||
|
||||
const newRect = targetElement.getBoundingClientRect()
|
||||
setRect({
|
||||
x: newRect.x,
|
||||
y: newRect.y,
|
||||
width: newRect.width,
|
||||
height: newRect.height,
|
||||
top: newRect.top,
|
||||
right: newRect.right,
|
||||
bottom: newRect.bottom,
|
||||
left: newRect.left,
|
||||
})
|
||||
},
|
||||
throttleMs,
|
||||
[enabled, getTargetElement],
|
||||
{ leading: true, trailing: true }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isClientSide()) {
|
||||
setRect(initialRect)
|
||||
return
|
||||
}
|
||||
|
||||
const targetElement = getTargetElement()
|
||||
if (!targetElement) return
|
||||
|
||||
updateRect()
|
||||
|
||||
const cleanup: (() => void)[] = []
|
||||
|
||||
if (useResizeObserver && hasResizeObserver) {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
window.requestAnimationFrame(updateRect)
|
||||
})
|
||||
resizeObserver.observe(targetElement)
|
||||
cleanup.push(() => resizeObserver.disconnect())
|
||||
}
|
||||
|
||||
const handleUpdate = () => updateRect()
|
||||
|
||||
window.addEventListener("scroll", handleUpdate, true)
|
||||
window.addEventListener("resize", handleUpdate, true)
|
||||
|
||||
cleanup.push(() => {
|
||||
window.removeEventListener("scroll", handleUpdate)
|
||||
window.removeEventListener("resize", handleUpdate)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cleanup.forEach((fn) => fn())
|
||||
setRect(initialRect)
|
||||
}
|
||||
}, [enabled, getTargetElement, updateRect, useResizeObserver])
|
||||
|
||||
return rect
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience hook for tracking document.body rect
|
||||
*/
|
||||
export function useBodyRect(
|
||||
options: Omit<ElementRectOptions, "element"> = {}
|
||||
): RectState {
|
||||
return useElementRect({
|
||||
...options,
|
||||
element: isClientSide() ? document.body : null,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience hook for tracking a ref element's rect
|
||||
*/
|
||||
export function useRefRect<T extends Element>(
|
||||
ref: React.RefObject<T>,
|
||||
options: Omit<ElementRectOptions, "element"> = {}
|
||||
): RectState {
|
||||
return useElementRect({ ...options, element: ref })
|
||||
}
|
||||
35
src/hooks/use-is-breakpoint.ts
Normal file
35
src/hooks/use-is-breakpoint.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
type BreakpointMode = "min" | "max"
|
||||
|
||||
/**
|
||||
* Hook to detect whether the current viewport matches a given breakpoint rule.
|
||||
* Example:
|
||||
* useIsBreakpoint("max", 768) // true when width < 768
|
||||
* useIsBreakpoint("min", 1024) // true when width >= 1024
|
||||
*/
|
||||
export function useIsBreakpoint(
|
||||
mode: BreakpointMode = "max",
|
||||
breakpoint = 768
|
||||
) {
|
||||
const [matches, setMatches] = useState<boolean | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
const query =
|
||||
mode === "min"
|
||||
? `(min-width: ${breakpoint}px)`
|
||||
: `(max-width: ${breakpoint - 1}px)`
|
||||
|
||||
const mql = window.matchMedia(query)
|
||||
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches)
|
||||
|
||||
// Set initial value
|
||||
setMatches(mql.matches)
|
||||
|
||||
// Add listener
|
||||
mql.addEventListener("change", onChange)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [mode, breakpoint])
|
||||
|
||||
return !!matches
|
||||
}
|
||||
194
src/hooks/use-menu-navigation.ts
Normal file
194
src/hooks/use-menu-navigation.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
type Orientation = "horizontal" | "vertical" | "both"
|
||||
|
||||
interface MenuNavigationOptions<T> {
|
||||
/**
|
||||
* The Tiptap editor instance, if using with a Tiptap editor.
|
||||
*/
|
||||
editor?: Editor | null
|
||||
/**
|
||||
* Reference to the container element for handling keyboard events.
|
||||
*/
|
||||
containerRef?: React.RefObject<HTMLElement | null>
|
||||
/**
|
||||
* Search query that affects the selected item.
|
||||
*/
|
||||
query?: string
|
||||
/**
|
||||
* Array of items to navigate through.
|
||||
*/
|
||||
items: T[]
|
||||
/**
|
||||
* Callback fired when an item is selected.
|
||||
*/
|
||||
onSelect?: (item: T) => void
|
||||
/**
|
||||
* Callback fired when the menu should close.
|
||||
*/
|
||||
onClose?: () => void
|
||||
/**
|
||||
* The navigation orientation of the menu.
|
||||
* @default "vertical"
|
||||
*/
|
||||
orientation?: Orientation
|
||||
/**
|
||||
* Whether to automatically select the first item when the menu opens.
|
||||
* @default true
|
||||
*/
|
||||
autoSelectFirstItem?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that implements keyboard navigation for dropdown menus and command palettes.
|
||||
*
|
||||
* Handles arrow keys, tab, home/end, enter for selection, and escape to close.
|
||||
* Works with both Tiptap editors and regular DOM elements.
|
||||
*
|
||||
* @param options - Configuration options for the menu navigation
|
||||
* @returns Object containing the selected index and a setter function
|
||||
*/
|
||||
export function useMenuNavigation<T>({
|
||||
editor,
|
||||
containerRef,
|
||||
query,
|
||||
items,
|
||||
onSelect,
|
||||
onClose,
|
||||
orientation = "vertical",
|
||||
autoSelectFirstItem = true,
|
||||
}: MenuNavigationOptions<T>) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(
|
||||
autoSelectFirstItem ? 0 : -1
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyboardNavigation = (event: KeyboardEvent) => {
|
||||
if (!items.length) return false
|
||||
|
||||
const moveNext = () =>
|
||||
setSelectedIndex((currentIndex) => {
|
||||
if (currentIndex === -1) return 0
|
||||
return (currentIndex + 1) % items.length
|
||||
})
|
||||
|
||||
const movePrev = () =>
|
||||
setSelectedIndex((currentIndex) => {
|
||||
if (currentIndex === -1) return items.length - 1
|
||||
return (currentIndex - 1 + items.length) % items.length
|
||||
})
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowUp": {
|
||||
if (orientation === "horizontal") return false
|
||||
event.preventDefault()
|
||||
movePrev()
|
||||
return true
|
||||
}
|
||||
|
||||
case "ArrowDown": {
|
||||
if (orientation === "horizontal") return false
|
||||
event.preventDefault()
|
||||
moveNext()
|
||||
return true
|
||||
}
|
||||
|
||||
case "ArrowLeft": {
|
||||
if (orientation === "vertical") return false
|
||||
event.preventDefault()
|
||||
movePrev()
|
||||
return true
|
||||
}
|
||||
|
||||
case "ArrowRight": {
|
||||
if (orientation === "vertical") return false
|
||||
event.preventDefault()
|
||||
moveNext()
|
||||
return true
|
||||
}
|
||||
|
||||
case "Tab": {
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) {
|
||||
movePrev()
|
||||
} else {
|
||||
moveNext()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
case "Home": {
|
||||
event.preventDefault()
|
||||
setSelectedIndex(0)
|
||||
return true
|
||||
}
|
||||
|
||||
case "End": {
|
||||
event.preventDefault()
|
||||
setSelectedIndex(items.length - 1)
|
||||
return true
|
||||
}
|
||||
|
||||
case "Enter": {
|
||||
if (event.isComposing) return false
|
||||
event.preventDefault()
|
||||
if (selectedIndex !== -1 && items[selectedIndex]) {
|
||||
onSelect?.(items[selectedIndex])
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
case "Escape": {
|
||||
event.preventDefault()
|
||||
onClose?.()
|
||||
return true
|
||||
}
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let targetElement: HTMLElement | null = null
|
||||
|
||||
if (editor) {
|
||||
targetElement = editor.view.dom
|
||||
} else if (containerRef?.current) {
|
||||
targetElement = containerRef.current
|
||||
}
|
||||
|
||||
if (targetElement) {
|
||||
targetElement.addEventListener("keydown", handleKeyboardNavigation, true)
|
||||
|
||||
return () => {
|
||||
targetElement?.removeEventListener(
|
||||
"keydown",
|
||||
handleKeyboardNavigation,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [
|
||||
editor,
|
||||
containerRef,
|
||||
items,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
onClose,
|
||||
orientation,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (query) {
|
||||
setSelectedIndex(autoSelectFirstItem ? 0 : -1)
|
||||
}
|
||||
}, [query, autoSelectFirstItem])
|
||||
|
||||
return {
|
||||
selectedIndex: items.length ? selectedIndex : undefined,
|
||||
setSelectedIndex,
|
||||
}
|
||||
}
|
||||
75
src/hooks/use-scrolling.ts
Normal file
75
src/hooks/use-scrolling.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { RefObject } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
type ScrollTarget = RefObject<HTMLElement> | Window | null | undefined
|
||||
type EventTargetWithScroll = Window | HTMLElement | Document
|
||||
|
||||
interface UseScrollingOptions {
|
||||
debounce?: number
|
||||
fallbackToDocument?: boolean
|
||||
}
|
||||
|
||||
export function useScrolling(
|
||||
target?: ScrollTarget,
|
||||
options: UseScrollingOptions = {}
|
||||
): boolean {
|
||||
const { debounce = 150, fallbackToDocument = true } = options
|
||||
const [isScrolling, setIsScrolling] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Resolve element or window
|
||||
const element: EventTargetWithScroll =
|
||||
target && typeof Window !== "undefined" && target instanceof Window
|
||||
? target
|
||||
: ((target as RefObject<HTMLElement>)?.current ?? window)
|
||||
|
||||
// Mobile: fallback to document when using window
|
||||
const eventTarget: EventTargetWithScroll =
|
||||
fallbackToDocument &&
|
||||
element === window &&
|
||||
typeof document !== "undefined"
|
||||
? document
|
||||
: element
|
||||
|
||||
const on = (
|
||||
el: EventTargetWithScroll,
|
||||
event: string,
|
||||
handler: EventListener
|
||||
) => el.addEventListener(event, handler, true)
|
||||
|
||||
const off = (
|
||||
el: EventTargetWithScroll,
|
||||
event: string,
|
||||
handler: EventListener
|
||||
) => el.removeEventListener(event, handler)
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
const supportsScrollEnd = element === window && "onscrollend" in window
|
||||
|
||||
const handleScroll: EventListener = () => {
|
||||
if (!isScrolling) setIsScrolling(true)
|
||||
|
||||
if (!supportsScrollEnd) {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => setIsScrolling(false), debounce)
|
||||
}
|
||||
}
|
||||
|
||||
const handleScrollEnd: EventListener = () => setIsScrolling(false)
|
||||
|
||||
on(eventTarget, "scroll", handleScroll)
|
||||
if (supportsScrollEnd) {
|
||||
on(eventTarget, "scrollend", handleScrollEnd)
|
||||
}
|
||||
|
||||
return () => {
|
||||
off(eventTarget, "scroll", handleScroll)
|
||||
if (supportsScrollEnd) {
|
||||
off(eventTarget, "scrollend", handleScrollEnd)
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [target, debounce, fallbackToDocument, isScrolling])
|
||||
|
||||
return isScrolling
|
||||
}
|
||||
48
src/hooks/use-throttled-callback.ts
Normal file
48
src/hooks/use-throttled-callback.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import throttle from "lodash.throttle"
|
||||
|
||||
import { useUnmount } from "@/hooks/use-unmount"
|
||||
import { useMemo } from "react"
|
||||
|
||||
interface ThrottleSettings {
|
||||
leading?: boolean | undefined
|
||||
trailing?: boolean | undefined
|
||||
}
|
||||
|
||||
const defaultOptions: ThrottleSettings = {
|
||||
leading: false,
|
||||
trailing: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that returns a throttled callback function.
|
||||
*
|
||||
* @param fn The function to throttle
|
||||
* @param wait The time in ms to wait before calling the function
|
||||
* @param dependencies The dependencies to watch for changes
|
||||
* @param options The throttle options
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function useThrottledCallback<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
wait = 250,
|
||||
dependencies: React.DependencyList = [],
|
||||
options: ThrottleSettings = defaultOptions
|
||||
): {
|
||||
(this: ThisParameterType<T>, ...args: Parameters<T>): ReturnType<T>
|
||||
cancel: () => void
|
||||
flush: () => void
|
||||
} {
|
||||
const handler = useMemo(
|
||||
() => throttle<T>(fn, wait, options),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
dependencies
|
||||
)
|
||||
|
||||
useUnmount(() => {
|
||||
handler.cancel()
|
||||
})
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
export default useThrottledCallback
|
||||
21
src/hooks/use-unmount.ts
Normal file
21
src/hooks/use-unmount.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useRef, useEffect } from "react"
|
||||
|
||||
/**
|
||||
* Hook that executes a callback when the component unmounts.
|
||||
*
|
||||
* @param callback Function to be called on component unmount
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const useUnmount = (callback: (...args: Array<any>) => any) => {
|
||||
const ref = useRef(callback)
|
||||
ref.current = callback
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
ref.current()
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
export default useUnmount
|
||||
93
src/hooks/use-window-size.ts
Normal file
93
src/hooks/use-window-size.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useThrottledCallback } from "@/hooks/use-throttled-callback"
|
||||
|
||||
export interface WindowSizeState {
|
||||
/**
|
||||
* The width of the window's visual viewport in pixels.
|
||||
*/
|
||||
width: number
|
||||
/**
|
||||
* The height of the window's visual viewport in pixels.
|
||||
*/
|
||||
height: number
|
||||
/**
|
||||
* The distance from the top of the visual viewport to the top of the layout viewport.
|
||||
* Particularly useful for handling mobile keyboard appearance.
|
||||
*/
|
||||
offsetTop: number
|
||||
/**
|
||||
* The distance from the left of the visual viewport to the left of the layout viewport.
|
||||
*/
|
||||
offsetLeft: number
|
||||
/**
|
||||
* The scale factor of the visual viewport.
|
||||
* This is useful for scaling elements based on the current zoom level.
|
||||
*/
|
||||
scale: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that tracks the window's visual viewport dimensions, position, and provides
|
||||
* a CSS transform for positioning elements.
|
||||
*
|
||||
* Uses the Visual Viewport API to get accurate measurements, especially important
|
||||
* for mobile devices where virtual keyboards can change the visible area.
|
||||
* Only updates state when values actually change to optimize performance.
|
||||
*
|
||||
* @returns An object containing viewport properties and a CSS transform string
|
||||
*/
|
||||
export function useWindowSize(): WindowSizeState {
|
||||
const [windowSize, setWindowSize] = useState<WindowSizeState>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
scale: 0,
|
||||
})
|
||||
|
||||
const handleViewportChange = useThrottledCallback(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const vp = window.visualViewport
|
||||
if (!vp) return
|
||||
|
||||
const {
|
||||
width = 0,
|
||||
height = 0,
|
||||
offsetTop = 0,
|
||||
offsetLeft = 0,
|
||||
scale = 0,
|
||||
} = vp
|
||||
|
||||
setWindowSize((prevState) => {
|
||||
if (
|
||||
width === prevState.width &&
|
||||
height === prevState.height &&
|
||||
offsetTop === prevState.offsetTop &&
|
||||
offsetLeft === prevState.offsetLeft &&
|
||||
scale === prevState.scale
|
||||
) {
|
||||
return prevState
|
||||
}
|
||||
|
||||
return { width, height, offsetTop, offsetLeft, scale }
|
||||
})
|
||||
}, 200)
|
||||
|
||||
useEffect(() => {
|
||||
const visualViewport = window.visualViewport
|
||||
if (!visualViewport) return
|
||||
|
||||
visualViewport.addEventListener("resize", handleViewportChange)
|
||||
|
||||
handleViewportChange()
|
||||
|
||||
return () => {
|
||||
visualViewport.removeEventListener("resize", handleViewportChange)
|
||||
}
|
||||
}, [handleViewportChange])
|
||||
|
||||
return windowSize
|
||||
}
|
||||
@@ -18,6 +18,22 @@ interface User {
|
||||
};
|
||||
过期时间?: string;
|
||||
};
|
||||
AI配置?: {
|
||||
使用模式: 'system' | 'custom';
|
||||
自定义助手列表: {
|
||||
名称: string;
|
||||
功能类型: 'chat' | 'text' | 'image' | 'video';
|
||||
API_Endpoint?: string;
|
||||
API_Key?: string;
|
||||
Model?: string;
|
||||
系统提示词?: string;
|
||||
}[];
|
||||
当前助手ID?: string;
|
||||
系统用量?: {
|
||||
今日调用次数: number;
|
||||
剩余免费额度: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
|
||||
Reference in New Issue
Block a user